mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge pull request #580: feat(tabs): search sessions by name on the vertical rail
This commit is contained in:
commit
2881ef5fa5
11 files changed
+1069
-13
No files matched your search
+125
-11
@@ -717,6 +717,7 @@ class CodemanApp {
|
||||
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||
this._lastTabGroupStructureKey = null;
|
||||
this._tabRailSearch = ''; // rail search box text: in memory only, never persisted
|
||||
this.cases = [];
|
||||
this.currentRun = null;
|
||||
this.totalTokens = 0;
|
||||
@@ -5755,27 +5756,132 @@ class CodemanApp {
|
||||
*/
|
||||
applySidebarFilter(query) {
|
||||
this._sidebarFilter = (query ?? '').trim().toLowerCase();
|
||||
this._applyTabListFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE row filter behind both search boxes: the sidebar's filter box and
|
||||
* the vertical rail's search box (only one of the two hosts the list at a
|
||||
* time). Classes only, over whatever the last render drew, so grouping, order,
|
||||
* Alt+N badges and the server layout never move; the matching itself is the
|
||||
* pure CodemanTabSearch (constants.js).
|
||||
*
|
||||
* - Sidebar: name (aria-label) + working directory (title), as it always has.
|
||||
* - Rail: the NAME only, a web tab's title included (it is a row in the same
|
||||
* list, and hiding every web tab would make a dashboard unfindable).
|
||||
*
|
||||
* A session row with a tab alert (red action or yellow idle, whatever
|
||||
* tabAlerts holds, the set a collapsed group header surfaces) stays visible
|
||||
* even when it does not match: a prompt waiting on you is never hidden by a
|
||||
* view filter. Alerts come and go through renderSessionTabs(), and both
|
||||
* render paths end here, so nothing else re-runs this for them.
|
||||
*
|
||||
* A group or case box left with nothing showing hides with its header, its
|
||||
* count shows the rows left showing (a kept row included), and the grouped
|
||||
* tree's roving stop and posinset follow the visible items. A collapsed group's rows are not in the DOM at all,
|
||||
* which is why the rail search also expands the projection (_projectTabGroups).
|
||||
*/
|
||||
_applyTabListFilter() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
const reachable =
|
||||
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const needle = reachable ? this._sidebarFilter : '';
|
||||
const rail = this._tabOrientation() === 'vertical';
|
||||
const sidebarReachable =
|
||||
!rail && this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const query = rail ? this._tabRailSearch : sidebarReachable ? this._sidebarFilter : '';
|
||||
const rows = [...container.querySelectorAll('.session-tab')].map((tab) => ({
|
||||
key: tab,
|
||||
text: rail
|
||||
? this._tabRowSearchName(tab)
|
||||
: `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`,
|
||||
section: tab.closest('.tab-layout-group, .tab-cluster'),
|
||||
// Web tabs carry no alerts; only a session row can be kept.
|
||||
keep: !tab.dataset.webviewId && !!tab.dataset.id && !!this.tabAlerts?.get(tab.dataset.id),
|
||||
}));
|
||||
const result = window.CodemanTabSearch?.filter(rows, query);
|
||||
if (!result) return;
|
||||
// State headings count the whole group, so they step aside while a filter
|
||||
// is narrowing the rows under them (styles.css, .tabs-filtering).
|
||||
container.classList.toggle('tabs-filtering', !!needle);
|
||||
for (const tab of container.querySelectorAll('.session-tab')) {
|
||||
if (!needle) {
|
||||
tab.classList.remove('tab-filtered-out');
|
||||
continue;
|
||||
container.classList.toggle('tabs-filtering', result.active);
|
||||
for (const row of rows) row.key.classList.toggle('tab-filtered-out', result.hidden.has(row.key));
|
||||
for (const section of container.querySelectorAll('.tab-layout-group, .tab-cluster')) {
|
||||
const shown = result.counts.get(section) ?? 0;
|
||||
section.classList.toggle('tab-filtered-out', result.active && shown === 0);
|
||||
const count = section.querySelector('.tab-layout-group-count, .tab-cluster-count');
|
||||
if (!count) continue;
|
||||
if (count.dataset.total === undefined) count.dataset.total = count.textContent;
|
||||
const text = result.active ? String(shown) : count.dataset.total;
|
||||
if (count.textContent !== text) count.textContent = text;
|
||||
}
|
||||
const empty = document.getElementById('tabRailSearchEmpty');
|
||||
if (empty) empty.hidden = !(rail && result.active && result.matchCount === 0);
|
||||
if (container.getAttribute('role') === 'tree') {
|
||||
const items = this._applyTabTreePositions(container);
|
||||
const stop = container.querySelector('[role="treeitem"][tabindex="0"]');
|
||||
if (items.length && !items.includes(stop)) {
|
||||
this._setTabTreeStop(container, items.find((item) => item.getAttribute('aria-selected') === 'true') || items[0]);
|
||||
}
|
||||
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
|
||||
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
|
||||
}
|
||||
// The count shows visible rows, so it moves with every filter change —
|
||||
// including keystrokes in the filter box, which call this directly.
|
||||
this.updateSidebarCount();
|
||||
}
|
||||
|
||||
/** What the rail search matches on a row: a session's name, a web tab's title. */
|
||||
_tabRowSearchName(tab) {
|
||||
if (tab.dataset.webviewId) return this.webviews?.get(tab.dataset.webviewId)?.name || '';
|
||||
return tab.querySelector('.tab-name')?.dataset.fullName || '';
|
||||
}
|
||||
|
||||
/** True while the vertical rail's search box is narrowing the list. */
|
||||
_tabRailSearchActive() {
|
||||
return this._tabOrientation() === 'vertical' && !!window.CodemanTabSearch?.needle(this._tabRailSearch);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail search box's input handler. In-memory only: never persisted, never
|
||||
* sent anywhere. Starting or ending a search re-renders once when it changes
|
||||
* what a collapsed group hides (the projection ignores collapse while
|
||||
* searching); every other keystroke only re-applies the row classes.
|
||||
*/
|
||||
setTabRailSearch(value) {
|
||||
this._tabRailSearch = typeof value === 'string' ? value : '';
|
||||
const clear = document.getElementById('tabRailSearchClear');
|
||||
if (clear) clear.hidden = this._tabRailSearch.length === 0;
|
||||
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
||||
else this._applyTabListFilter();
|
||||
}
|
||||
|
||||
/** Clear button (and Escape): empty the box, restore the list, keep focus in the box. */
|
||||
clearTabRailSearch() {
|
||||
const input = document.getElementById('tabRailSearch');
|
||||
if (input) input.value = '';
|
||||
this.setTabRailSearch('');
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
handleTabRailSearchKeydown(event) {
|
||||
if (event.key !== 'Escape' || !this._tabRailSearch) return;
|
||||
// Consumed here: the global Escape handler would otherwise close whatever
|
||||
// else is open on the same keypress.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.clearTabRailSearch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the search without rendering: the list is leaving the rail
|
||||
* (applyTabOrientation), and the render that follows draws it unfiltered.
|
||||
*/
|
||||
_resetTabRailSearch() {
|
||||
this._tabRailSearch = '';
|
||||
const input = document.getElementById('tabRailSearch');
|
||||
if (input) input.value = '';
|
||||
const clear = document.getElementById('tabRailSearchClear');
|
||||
if (clear) clear.hidden = true;
|
||||
const empty = document.getElementById('tabRailSearchEmpty');
|
||||
if (empty) empty.hidden = true;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Rich sidebar rows (sessionListLayout === 'sidebar-rich')
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -6984,6 +7090,8 @@ class CodemanApp {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
const items = [];
|
||||
for (const section of container.querySelectorAll('.tab-layout-group')) {
|
||||
// A group the search emptied is hidden whole, header included.
|
||||
if (section.classList.contains('tab-filtered-out')) continue;
|
||||
const header = section.querySelector(':scope > [role="treeitem"]');
|
||||
if (header) items.push(header);
|
||||
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
|
||||
@@ -7387,7 +7495,10 @@ class CodemanApp {
|
||||
// session the layout has not placed yet lands where the flat strip has it.
|
||||
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
|
||||
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
|
||||
collapsedGroupIds: [...this.collapsedTabGroupIds],
|
||||
// A rail search shows matches inside collapsed groups too, so it projects
|
||||
// every group open. The stored per-device collapse state is untouched and
|
||||
// applies again as soon as the search is cleared.
|
||||
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...this.collapsedTabGroupIds],
|
||||
activeSessionId: this.activeSessionId,
|
||||
activeWebviewId: this.activeWebviewId,
|
||||
});
|
||||
@@ -7408,6 +7519,9 @@ class CodemanApp {
|
||||
* A storage failure leaves every group expanded rather than half-remembered.
|
||||
*/
|
||||
toggleTabGroupCollapsed(groupId, forceCollapsed) {
|
||||
// Every group is drawn open while the rail search runs; a toggle then would
|
||||
// change what the user sees only after the search is cleared.
|
||||
if (this._tabRailSearchActive()) return false;
|
||||
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
|
||||
const next = new Set(this.collapsedTabGroupIds);
|
||||
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
|
||||
|
||||
@@ -1020,6 +1020,54 @@ function tabClusterNameSplit(name, label) {
|
||||
return match[2].slice(1).toLowerCase() === label.toLowerCase() ? { shown: match[1], hidden: match[2] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-list search: the vertical rail's search box and the sidebar's filter
|
||||
* box. Trimmed, case-insensitive substring; a whitespace-only query is no query.
|
||||
* @param {unknown} query
|
||||
* @returns {string} the needle, '' when there is nothing to search for
|
||||
*/
|
||||
function tabSearchNeedle(query) {
|
||||
return typeof query === 'string' ? query.trim().toLocaleLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rows a search hides. Pure: the caller reads the rows off the list it
|
||||
* rendered and applies the result as classes, so the list itself (grouping,
|
||||
* order, Alt+N badges) is never rebuilt or reordered by a search.
|
||||
*
|
||||
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
|
||||
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
|
||||
* view filter). It counts toward its section, so its group stays on screen with
|
||||
* it, but not toward `matchCount`.
|
||||
*
|
||||
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
|
||||
* in list order; `section` is the row's group or case box, null/undefined for none.
|
||||
* @param {unknown} query
|
||||
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
|
||||
* `counts` is the rows left showing per section (kept rows included), every
|
||||
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
|
||||
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
|
||||
*/
|
||||
function filterTabSearchRows(rows, query) {
|
||||
const needle = tabSearchNeedle(query);
|
||||
const hidden = new Set();
|
||||
const counts = new Map();
|
||||
let matchCount = 0;
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
const hasSection = row.section !== null && row.section !== undefined;
|
||||
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
|
||||
const text = typeof row.text === 'string' ? row.text.toLocaleLowerCase() : '';
|
||||
const matches = !needle || text.includes(needle);
|
||||
if (!matches && row.keep !== true) {
|
||||
hidden.add(row.key);
|
||||
continue;
|
||||
}
|
||||
if (matches) matchCount++;
|
||||
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
|
||||
}
|
||||
return { active: needle.length > 0, hidden, counts, matchCount };
|
||||
}
|
||||
|
||||
// Terminal font stack — the single source for every xterm surface (the main
|
||||
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||
@@ -1415,6 +1463,10 @@ if (typeof window !== 'undefined') {
|
||||
groupFor: tabTriageGroupFor,
|
||||
layout: computeTabTriageLayout,
|
||||
};
|
||||
window.CodemanTabSearch = {
|
||||
needle: tabSearchNeedle,
|
||||
filter: filterTabSearchRows,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
|
||||
@@ -62,6 +62,8 @@
|
||||
'Collapse session sidebar': '收起会话侧边栏',
|
||||
'Expand session sidebar': '展开会话侧边栏',
|
||||
'Filter sessions': '筛选会话',
|
||||
'Search sessions': '搜索会话',
|
||||
'No sessions match': '没有匹配的会话',
|
||||
'Admin Panel': '管理面板',
|
||||
'Open admin panel': '打开管理面板',
|
||||
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
|
||||
|
||||
@@ -394,6 +394,19 @@
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
|
||||
<!-- Session-name search (app.js setTabRailSearch): a view filter over the
|
||||
rows below, in memory only. Shown with the rail, never elsewhere. -->
|
||||
<div class="session-sidebar-filter tab-rail-search">
|
||||
<input type="search" id="tabRailSearch" class="session-sidebar-filter-input"
|
||||
placeholder="Search sessions" aria-label="Search sessions"
|
||||
autocomplete="off" spellcheck="false"
|
||||
oninput="app.setTabRailSearch(this.value)"
|
||||
onkeydown="app.handleTabRailSearchKeydown(event)">
|
||||
<button type="button" id="tabRailSearchClear" class="tab-rail-search-clear"
|
||||
aria-label="Clear search" title="Clear search"
|
||||
onclick="app.clearTabRailSearch()" hidden>×</button>
|
||||
</div>
|
||||
<div id="tabRailSearchEmpty" class="tab-rail-search-empty" role="status" hidden>No sessions match</div>
|
||||
<div
|
||||
id="tabRailResizeHandle"
|
||||
class="tab-rail-resize-handle"
|
||||
|
||||
@@ -3997,6 +3997,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
})
|
||||
: 'horizontal';
|
||||
|
||||
// The search box lives in the rail: a search left applied after the list
|
||||
// moves out would hide tabs with no box to clear it from.
|
||||
if (orientation !== 'vertical' && this._tabRailSearch) this._resetTabRailSearch?.();
|
||||
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
|
||||
root.setAttribute('data-tab-orientation', orientation);
|
||||
|
||||
@@ -18819,6 +18819,74 @@ html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The vertical rail's session search (app.js _applyTabListFilter) uses the same
|
||||
class, and also hides a group or case box the search emptied. Rail-scoped for
|
||||
the same reason as the sidebar rule above: the header strip has no box. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tab.tab-filtered-out,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group.tab-filtered-out,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-cluster.tab-filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The box reuses .session-sidebar-filter / -input; this only adds the clear
|
||||
button over its right edge and the "No sessions match" line. */
|
||||
.tab-rail-search {
|
||||
position: relative;
|
||||
/* Clear of the 16px resize handle on the rail's right edge. */
|
||||
padding-right: calc(0.25rem + 16px);
|
||||
}
|
||||
|
||||
.tab-rail-search .session-sidebar-filter-input {
|
||||
padding-right: 1.6rem;
|
||||
}
|
||||
|
||||
/* The native WebKit clear glyph would sit under ours. */
|
||||
.tab-rail-search .session-sidebar-filter-input::-webkit-search-cancel-button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.tab-rail-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: calc(0.45rem + 16px);
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
padding: 0;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-rail-search-clear:hover,
|
||||
.tab-rail-search-clear:focus-visible {
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-rail-search-clear[hidden],
|
||||
.tab-rail-search-empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-rail-search-empty {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Every group is drawn open while searching and its header will not toggle,
|
||||
so the chevron dims to say so. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs.tabs-filtering .tab-layout-group-chevron {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
|
||||
/* The detailed variant of the SAME sidebar: identical column, identical
|
||||
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
|
||||
|
||||
Reference in new issue
Block a user