feat(tabs): search sessions by name on the vertical rail

A "Search sessions" box at the top of the vertical tab rail narrows the
list to the tabs whose name matches (case-insensitive substring; a web
tab matches by its title). It searches every group, collapsed ones
included: while a search runs the projection draws every group open and
the header will not toggle, and the stored per-device collapse state is
left alone. Groups with no match hide, an empty result says "No sessions
match", and the flat rail (no groups) filters the same way. Escape or
the clear button empties it; leaving the vertical orientation resets it.

It is a view filter only: rows get the same tab-filtered-out class the
sidebar filter box uses, through one shared _applyTabListFilter() over
the pure CodemanTabSearch matcher in constants.js. Grouping, order,
Alt+N badges and drag are untouched, nothing is persisted or sent to
the server. The sidebar keeps matching name plus working directory.

In the grouped tree, hidden rows and the headers of emptied groups leave
the roving walk and posinset/setsize, and the tab stop moves onto a
visible item. zh-CN strings added.
This commit is contained in:
Aamer Akhter committed 2026-10-09 20:55:32 -04:00
1 parent b59145effd
commit 7c3877016b
11 files changed
+964 -13

No files matched your search

+117 -11
View File
@@ -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;
@@ -5738,27 +5739,124 @@ 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 group or case box left with no match hides with its header, its count
* shows the matches, 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'),
}));
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')
// ═══════════════════════════════════════════════════════════════
@@ -6965,6 +7063,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)')];
@@ -7368,7 +7468,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,
});
@@ -7389,6 +7492,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;
+45
View File
@@ -1017,6 +1017,47 @@ 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.
*
* @param {Array<{key: unknown, text: string, section?: unknown}>} 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` has every section seen, an emptied one as 0, so it can be hidden;
* `matchCount` is the number of rows left showing.
*/
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() : '';
if (needle && !text.includes(needle)) {
hidden.add(row.key);
continue;
}
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/ +
@@ -1355,6 +1396,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,
+2
View File
@@ -61,6 +61,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)': '重新停靠到主界面(关闭窗口)',
+13
View File
@@ -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>&times;</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"
+4
View File
@@ -3924,6 +3924,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);
+68
View File
@@ -18782,6 +18782,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