Merge pull request #580: feat(tabs): search sessions by name on the vertical rail

This commit is contained in:
Codeman maintainer committed 2026-10-10 03:54:39 +02:00
commit 2881ef5fa5
11 files changed
+1069 -13

No files matched your search

+125 -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;
@@ -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;
+52
View File
@@ -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,
+2
View File
@@ -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)': '重新停靠到主界面(关闭窗口)',
+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
@@ -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);
+68
View File
@@ -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