mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #582: feat(tabs): pin tabs to a Focus section on the vertical rail
Conflicts resolved against #528: - CLAUDE.md Owner tab layouts: #582's Focus sentences and #528's tabGroupId / spawnedTabsFollowParent sentences are both kept. - i18n.js: both features' ZH_CN entries kept. - tab-layout-browser.js: the section-model JSDoc documents #528's `options.parents` and #582's `focusRefs` return field. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
a6ce52d3e7
15 files changed
+1819
-16
No files matched your search
@@ -262,7 +262,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/tab-layout-editing.browser.test.ts',
|
||||
'test/tab-rail-search.browser.test.ts',
|
||||
'test/tab-focus.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
|
||||
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
|
||||
| -------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
|
||||
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. Desktop and tablet only. |
|
||||
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; Escape or × clears it, and it is never saved. **Focus:** *Add to Focus* in a row's ⋯ menu (right-click on a web tab) pins a shortcut to that tab in a **Focus** section at the top of the rail, with or without groups; the tab itself stays where it is. Click a shortcut to switch to its tab, right-click it (or press the menu key) for *Remove from Focus*. Focus follows you across devices; whether it is collapsed is kept per device. Desktop and tablet only. |
|
||||
|
||||
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
|
||||
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
|
||||
|
||||
+21
-2
@@ -15,6 +15,13 @@ export interface TabRef {
|
||||
kind: TabRefKind;
|
||||
id: string;
|
||||
placement?: 'manual';
|
||||
/**
|
||||
* Pinned to the vertical rail's Focus section. The browser draws a SHORTCUT to
|
||||
* the ref there; the ref itself stays in its group, so the flag never moves
|
||||
* anything and nothing that moves a ref may drop it. Only `true` is stored:
|
||||
* an unfocused ref carries no key at all.
|
||||
*/
|
||||
focus?: true;
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
@@ -104,7 +111,13 @@ function parseRef(value: unknown, label: string): TabRef {
|
||||
if (value.placement !== undefined && value.placement !== 'manual') {
|
||||
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
|
||||
}
|
||||
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
|
||||
if (value.focus !== undefined && value.focus !== true) {
|
||||
throw new TabLayoutValidationError(`${label}.focus must be true when present`);
|
||||
}
|
||||
const ref: TabRef = { kind: value.kind, id };
|
||||
if (value.placement === 'manual') ref.placement = 'manual';
|
||||
if (value.focus === true) ref.focus = true;
|
||||
return ref;
|
||||
}
|
||||
|
||||
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
|
||||
@@ -395,6 +408,11 @@ export function setManualPlacement(input: TabLayout, target: TabRef, manual: boo
|
||||
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
|
||||
}
|
||||
|
||||
/** Pin `target` to the Focus section, or unpin it. Moves nothing; idempotent both ways. */
|
||||
export function setTabFocus(input: TabLayout, target: TabRef, focused: boolean): TabLayout {
|
||||
return mapRef(input, target, ({ focus: _focus, ...ref }) => (focused ? { ...ref, focus: true } : ref));
|
||||
}
|
||||
|
||||
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
|
||||
const normalized = normalizeTabLayout(input, metadata);
|
||||
if (target.kind !== 'session') {
|
||||
@@ -420,7 +438,8 @@ export function followParent(input: TabLayout, target: TabRef, metadata: readonl
|
||||
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
|
||||
}
|
||||
|
||||
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
|
||||
// Clear the manual placement only: a focused ref stays focused.
|
||||
const cleared = mapRef(normalized, target, ({ placement: _placement, ...ref }) => ref);
|
||||
return normalizeTabLayout(cleared, metadata);
|
||||
}
|
||||
|
||||
|
||||
+236
-2
@@ -6183,6 +6183,8 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
this._syncTabTreeSelection(container);
|
||||
// The Focus shortcut of the selected tab is marked aria-current.
|
||||
this._renderTabFocus();
|
||||
// #257: selection used to stop at the class toggle. On phones/tablets the
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
@@ -6674,6 +6676,10 @@ class CodemanApp {
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
// Same deal for the desktop home screen's tab column.
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
// Focus shortcuts carry the status dot and alert of the tab they stand for.
|
||||
// A no-op unless what the section would draw changed (and after a full
|
||||
// rebuild above it already has).
|
||||
this._renderTabFocus();
|
||||
// The full-render path already redraws the connection SVG; this incremental
|
||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||
// it, sliding the lineage lines off their anchors. Only pay for it when there
|
||||
@@ -7027,6 +7033,10 @@ class CodemanApp {
|
||||
// Set up keyboard navigation for tabs
|
||||
this.setupTabKeyboardNavigation(container);
|
||||
|
||||
// The Focus section sits above this list in the rail, so it is drawn before
|
||||
// the connection lines measure the rows.
|
||||
this._renderTabFocus();
|
||||
|
||||
// Update connection lines after tabs change (positions may have shifted)
|
||||
this.updateConnectionLines();
|
||||
|
||||
@@ -7593,6 +7603,9 @@ class CodemanApp {
|
||||
// rail (always so on the flat rail, which is every owner without groups).
|
||||
// Rebuild only when what the rail would draw actually changed.
|
||||
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
|
||||
// A Focus flag set on another device is not a structural change of the
|
||||
// list, so it would not rebuild it: draw the section on its own.
|
||||
else if (this._renderTabFocus()) this.updateConnectionLines?.();
|
||||
// Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
|
||||
if (next && !this._tabLayoutRestoreChecked) {
|
||||
this._tabLayoutRestoreChecked = true;
|
||||
@@ -7668,6 +7681,226 @@ class CodemanApp {
|
||||
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: the Focus section (pinned shortcuts)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// A ref flagged `focus: true` in the layout gets a SHORTCUT in a small
|
||||
// "Focus" section pinned to the top of the vertical rail; the row itself stays
|
||||
// in its group. The section is its own element in #tabRail, OUTSIDE
|
||||
// #sessionTabs, on purpose: it shows above the flat rail as well as the
|
||||
// grouped one, and nothing that walks #sessionTabs (Alt+N badges, the tree's
|
||||
// roving stop and posinset, lineage lines, drag, the row filters, the rail
|
||||
// sort) ever sees a shortcut. Its own keyboard model is a list with one tab
|
||||
// stop and Up/Down/Home/End between shortcuts; each shortcut is a button, so
|
||||
// Enter/Space select its tab, and the context-menu key opens "Remove from
|
||||
// Focus". Collapse is per-device localStorage, like a group's.
|
||||
|
||||
/** True when `ref` carries `focus: true` in the held layout. */
|
||||
_tabRefFocused(ref) {
|
||||
const location = this._tabRefLocation(ref);
|
||||
return location?.refs[location.index]?.focus === true;
|
||||
}
|
||||
|
||||
/** Pin or unpin a row. One named operation through the edit coordinator. */
|
||||
setTabRefFocus(ref, focused) {
|
||||
if (!this._tabRefLocation(ref)) return false;
|
||||
return this.editTabLayout({ type: 'setFocus', ref: { kind: ref.kind, id: ref.id }, focused: focused === true }, `${ref.kind}:${ref.id}`);
|
||||
}
|
||||
|
||||
/** The row menu's Focus entry ([] where the rail cannot edit the layout). */
|
||||
_tabRefFocusActions(ref) {
|
||||
if (!this._tabLayoutEditable() || !this._tabRefLocation(ref)) return [];
|
||||
const focused = this._tabRefFocused(ref);
|
||||
return [{ label: focused ? 'Remove from Focus' : 'Add to Focus', run: () => this.setTabRefFocus(ref, !focused) }];
|
||||
}
|
||||
|
||||
_tabFocusCollapsedNow() {
|
||||
if (this._tabFocusCollapsed === undefined) {
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const loaded = storage ? window.CodemanTabLayout.loadFocusCollapsed(storage) : { collapsed: false, ok: false };
|
||||
if (!loaded.ok) this._tabCollapseStorageFailed = true;
|
||||
this._tabFocusCollapsed = loaded.collapsed;
|
||||
}
|
||||
return this._tabFocusCollapsed;
|
||||
}
|
||||
|
||||
/** Collapse/expand the Focus section. Per-device; a store failure leaves it expanded. */
|
||||
toggleTabFocusCollapsed(forceCollapsed) {
|
||||
const shouldCollapse = forceCollapsed === undefined ? !this._tabFocusCollapsedNow() : forceCollapsed === true;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const saved = storage ? window.CodemanTabLayout.saveFocusCollapsed(storage, shouldCollapse) : { collapsed: false, ok: false };
|
||||
if (!saved.ok) this._tabCollapseStorageFailed = true;
|
||||
this._tabFocusCollapsed = saved.collapsed;
|
||||
if (this._renderTabFocus()) this.updateConnectionLines?.();
|
||||
document.getElementById('tabFocusToggle')?.focus();
|
||||
return this._tabFocusCollapsed === shouldCollapse;
|
||||
}
|
||||
|
||||
/** The focused refs this rail draws now ([] off the vertical rail or without a layout). */
|
||||
_tabFocusRefs() {
|
||||
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return [];
|
||||
return window.CodemanTabLayout.focusRefs(this.tabLayout, {
|
||||
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
|
||||
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
|
||||
});
|
||||
}
|
||||
|
||||
/** The Focus section element, created once inside #tabRail just above the list. */
|
||||
_tabFocusHost() {
|
||||
let host = document.getElementById('tabFocus');
|
||||
if (host) return host;
|
||||
const rail = document.getElementById('tabRail');
|
||||
if (!rail) return null;
|
||||
host = document.createElement('section');
|
||||
host.id = 'tabFocus';
|
||||
host.className = 'tab-focus';
|
||||
host.hidden = true;
|
||||
host.setAttribute('aria-labelledby', 'tabFocusTitle');
|
||||
// Delegated, so the listeners survive every repaint of the section.
|
||||
host.addEventListener('keydown', (event) => this._handleTabFocusKeydown(event));
|
||||
host.addEventListener('click', (event) => {
|
||||
if (event.target?.closest?.('#tabFocusToggle')) {
|
||||
this.toggleTabFocusCollapsed();
|
||||
return;
|
||||
}
|
||||
const item = event.target?.closest?.('.tab-focus-item');
|
||||
if (item) this.openTabFocusShortcut(event, item.dataset.focusKey);
|
||||
});
|
||||
host.addEventListener('contextmenu', (event) => {
|
||||
const item = event.target?.closest?.('.tab-focus-item');
|
||||
if (item) this.openTabFocusMenu(event, item.dataset.focusKey, item);
|
||||
});
|
||||
const list = this.$('sessionTabs');
|
||||
if (list && list.parentElement === rail) rail.insertBefore(host, list);
|
||||
else rail.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the Focus section from the held layout. Cheap enough for every render
|
||||
* pass: the markup is compared with what was drawn last, and the DOM is only
|
||||
* touched when it changed. Returns true when it did (the rail's list moved,
|
||||
* so connector lines anchored to its rows need a redraw).
|
||||
*/
|
||||
_renderTabFocus() {
|
||||
const refs = this._tabFocusRefs();
|
||||
const host = refs.length ? this._tabFocusHost() : document.getElementById('tabFocus');
|
||||
if (!host) return false;
|
||||
if (!refs.length) {
|
||||
if (host.hidden && !host.firstChild) return false;
|
||||
host.hidden = true;
|
||||
host.replaceChildren();
|
||||
this._lastTabFocusHtml = '';
|
||||
return true;
|
||||
}
|
||||
// Keep the rail's list just below the section: applyTabOrientation()
|
||||
// re-appends #sessionTabs to the rail when the orientation flips.
|
||||
const list = this.$('sessionTabs');
|
||||
const rail = host.parentElement;
|
||||
if (list && rail && list.parentElement === rail && host.nextElementSibling !== list) rail.insertBefore(host, list);
|
||||
const collapsed = this._tabFocusCollapsedNow();
|
||||
const activeKey = this.activeWebviewId
|
||||
? `webview:${this.activeWebviewId}`
|
||||
: this.activeSessionId
|
||||
? `session:${this.activeSessionId}`
|
||||
: '';
|
||||
const focusedEl = host.contains(document.activeElement) ? document.activeElement : null;
|
||||
const focusedKey = focusedEl?.dataset?.focusKey || (focusedEl?.id === 'tabFocusToggle' ? 'toggle' : null);
|
||||
const keys = refs.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
// One tab stop in the list: the shortcut that had it, else the selected
|
||||
// tab's, else the first.
|
||||
const stopKey = [focusedKey, this._tabFocusStopKey, activeKey].find((key) => key && keys.includes(key)) || keys[0];
|
||||
const items = refs
|
||||
.map((ref) => {
|
||||
const key = `${ref.kind}:${ref.id}`;
|
||||
let name;
|
||||
let statusHtml;
|
||||
let alertClass = '';
|
||||
if (ref.kind === 'session') {
|
||||
const session = this.sessions.get(ref.id);
|
||||
name = this.getSessionName(session);
|
||||
statusHtml = `<span class="tab-status ${escapeHtml(session.status || 'idle')}" aria-hidden="true"></span>`;
|
||||
const alert = this.tabAlerts?.get(ref.id);
|
||||
alertClass = alert === 'action' ? ' tab-alert-action' : alert === 'idle' ? ' tab-alert-idle' : '';
|
||||
} else {
|
||||
const webview = this.webviews.get(ref.id);
|
||||
name = webview?.name || ref.id;
|
||||
statusHtml = `<span class="tab-web-icon" aria-hidden="true">${webview?.icon ? escapeHtml(webview.icon) : this._webviewGlobeIcon?.() || ''}</span>`;
|
||||
}
|
||||
// The name is user text: the whole button is kept away from the
|
||||
// translator (a session named "3 sessions" must not come out in Chinese).
|
||||
return (
|
||||
`<li class="tab-focus-entry"><button type="button" class="tab-focus-item${alertClass}" data-focus-key="${escapeHtml(key)}" data-i18n-skip ` +
|
||||
`tabindex="${key === stopKey ? '0' : '-1'}" aria-current="${key === activeKey ? 'true' : 'false'}" aria-label="${escapeHtml(name)}" title="${escapeHtml(name)}">` +
|
||||
`${statusHtml}<span class="tab-focus-name">${escapeHtml(name)}</span></button></li>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
const html =
|
||||
`<button type="button" id="tabFocusToggle" class="tab-focus-toggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="tabFocusList">` +
|
||||
`<span class="tab-layout-group-chevron" aria-hidden="true"></span><span class="tab-focus-title" id="tabFocusTitle">Focus</span>` +
|
||||
`<span class="tab-focus-count" aria-hidden="true">${refs.length}</span></button>` +
|
||||
`<ul class="tab-focus-list" id="tabFocusList" aria-labelledby="tabFocusTitle"${collapsed ? ' hidden' : ''}>${items}</ul>`;
|
||||
if (!host.hidden && this._lastTabFocusHtml === html) return false;
|
||||
host.classList.toggle('tab-focus--collapsed', collapsed);
|
||||
host.innerHTML = html;
|
||||
host.hidden = false;
|
||||
this._lastTabFocusHtml = html;
|
||||
if (focusedKey === 'toggle') document.getElementById('tabFocusToggle')?.focus();
|
||||
else if (focusedKey) host.querySelector(`[data-focus-key="${CSS.escape(focusedKey)}"]`)?.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
_tabFocusRefFromKey(key) {
|
||||
const at = typeof key === 'string' ? key.indexOf(':') : -1;
|
||||
if (at < 0) return null;
|
||||
const kind = key.slice(0, at);
|
||||
return kind === 'session' || kind === 'webview' ? { kind, id: key.slice(at + 1) } : null;
|
||||
}
|
||||
|
||||
/** A shortcut was clicked: select its tab, exactly as the row would. */
|
||||
openTabFocusShortcut(event, key) {
|
||||
const ref = this._tabFocusRefFromKey(key);
|
||||
if (!ref) return undefined;
|
||||
this._tabFocusStopKey = key;
|
||||
if (ref.kind === 'webview') return this.handleWebviewTabClick?.(event, ref.id);
|
||||
return this.handleSessionTabClick(event, ref.id);
|
||||
}
|
||||
|
||||
/** Right-click, Shift+F10 or the menu key on a shortcut (`trigger` anchors the menu). */
|
||||
openTabFocusMenu(event, key, trigger) {
|
||||
const ref = this._tabFocusRefFromKey(key);
|
||||
if (!ref) return false;
|
||||
this._tabFocusStopKey = key;
|
||||
// A delegated listener's currentTarget is the section; anchor on the shortcut.
|
||||
const anchored = trigger
|
||||
? { preventDefault: () => event?.preventDefault?.(), stopPropagation: () => event?.stopPropagation?.(), currentTarget: trigger, type: event?.type, clientX: event?.clientX }
|
||||
: event;
|
||||
return this._openTabLayoutMenu(anchored, `focus:${key}`, 'Focus actions', [
|
||||
{ label: 'Remove from Focus', run: () => this.setTabRefFocus(ref, false) },
|
||||
]);
|
||||
}
|
||||
|
||||
/** Up/Down/Home/End move between shortcuts; the list keeps one tab stop. */
|
||||
_handleTabFocusKeydown(event) {
|
||||
const current = event.target?.closest?.('.tab-focus-item');
|
||||
if (!current || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
const items = [...event.currentTarget.querySelectorAll('.tab-focus-item')];
|
||||
const index = items.indexOf(current);
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? items[0]
|
||||
: event.key === 'End'
|
||||
? items[items.length - 1]
|
||||
: items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
for (const item of items) item.tabIndex = item === next ? 0 : -1;
|
||||
this._tabFocusStopKey = next.dataset.focusKey;
|
||||
next.focus();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: editing groups from the vertical rail
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -7931,7 +8164,7 @@ class CodemanApp {
|
||||
// live tree item by identity.
|
||||
const rail = this.$('sessionTabs');
|
||||
const item =
|
||||
(trigger?.isConnected && trigger.closest('[role="treeitem"]')) ||
|
||||
(trigger?.isConnected && (trigger.closest('[role="treeitem"]') || trigger.closest('.tab-focus-item'))) ||
|
||||
[...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
|
||||
item?.focus();
|
||||
}
|
||||
@@ -7958,7 +8191,8 @@ class CodemanApp {
|
||||
|
||||
/** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */
|
||||
openTabWebviewMenu(event, webviewId) {
|
||||
const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId });
|
||||
const ref = { kind: 'webview', id: webviewId };
|
||||
const moves = [...this._tabRefFocusActions(ref), ...this._tabRefMoveActions(ref)];
|
||||
if (!moves.length) {
|
||||
this.showWebviewModal?.(webviewId);
|
||||
return false;
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
'Follow parent again': '重新跟随父会话',
|
||||
'Parent closed; placed on its own': '父会话已关闭;单独放置',
|
||||
'Parent loop; placed on its own': '父会话形成循环;单独放置',
|
||||
// The Focus section pinned at the top of the vertical rail (shortcuts to tabs).
|
||||
Focus: '焦点',
|
||||
'Add to Focus': '添加到焦点',
|
||||
'Remove from Focus': '从焦点移除',
|
||||
'Focus actions': '焦点操作',
|
||||
'Could not save tab groups.': '无法保存标签分组。',
|
||||
'Tab groups changed elsewhere; part of your edit no longer applies.':
|
||||
'标签分组已在别处更改;你的部分编辑已不再适用。',
|
||||
|
||||
@@ -828,6 +828,141 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layo
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* --- Focus section (pinned shortcuts, app.js _renderTabFocus) ---------- */
|
||||
/* Its own element above #sessionTabs in the rail, so it shows above the flat
|
||||
rail and the grouped one alike and never takes part in the list's order,
|
||||
tree or Alt+N numbering. Capped so a long Focus never starves the list. */
|
||||
.tab-focus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus:not([hidden]) {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 40%;
|
||||
min-height: 0;
|
||||
padding: 0.35rem 0.35rem 0.3rem calc(0.35rem + 24px);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 4px 6px 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail :is(.tab-focus-toggle, .tab-focus-item):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab-focus-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-focus-count {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-focus--collapsed .tab-layout-group-chevron::before {
|
||||
content: '\25B8';
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, var(--text-muted));
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item[aria-current='true'] {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.tab-focus-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The tab's own status dot and alert colour, so a shortcut says what its tab
|
||||
says (the row keeps its full alert ring; a shortcut only recolours the dot). */
|
||||
.tab-focus-item .tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--green);
|
||||
}
|
||||
.tab-focus-item .tab-status.error { background: var(--red); }
|
||||
.tab-focus-item .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
|
||||
.tab-focus-item.tab-alert-action .tab-status { background: var(--red); box-shadow: 0 0 6px rgba(239, 68, 68, 0.7); }
|
||||
.tab-focus-item.tab-alert-idle .tab-status { background: var(--yellow); box-shadow: 0 0 6px rgba(234, 179, 8, 0.6); }
|
||||
.tab-focus-item .tab-web-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-dim, var(--text-muted));
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||
* read degrades to the flat rail with a capped, backed-off retry.
|
||||
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
|
||||
* row) applied optimistically and saved through ONE serialized
|
||||
* row, pin a row to Focus) applied optimistically and saved through ONE serialized
|
||||
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
|
||||
* version conflict.
|
||||
*
|
||||
@@ -32,17 +32,26 @@
|
||||
'use strict';
|
||||
|
||||
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
|
||||
// The Focus section's collapse, per device. Its own key, not an entry in the
|
||||
// group list above: that list is garbage-collected against the layout's group
|
||||
// ids, and Focus is not a group.
|
||||
const FOCUS_COLLAPSED_STORAGE_KEY = 'codeman:tab-focus-collapsed';
|
||||
|
||||
const refKey = (ref) => `${ref.kind}:${ref.id}`;
|
||||
const validRef = (ref) =>
|
||||
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||
const stableIds = (value) => [...new Set(asIds(value))];
|
||||
// `placement: 'manual'` must survive the round trip: the browser writes whole
|
||||
// layouts back, and dropping it would re-attach a hand-placed child session to
|
||||
// its parent's subtree on the next save.
|
||||
const copyRef = (r) =>
|
||||
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
|
||||
// `placement: 'manual'` and `focus: true` must survive the round trip: the
|
||||
// browser writes whole layouts back, so a field dropped here is erased on the
|
||||
// next save, by whoever saves. Losing placement re-attaches a hand-placed child
|
||||
// session to its parent's subtree; losing focus unpins every Focus shortcut.
|
||||
const copyRef = (r) => {
|
||||
const copy = { kind: r.kind, id: r.id };
|
||||
if (r.placement === 'manual') copy.placement = 'manual';
|
||||
if (r.focus === true) copy.focus = true;
|
||||
return copy;
|
||||
};
|
||||
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
|
||||
|
||||
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
|
||||
@@ -108,6 +117,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Focus section's per-device collapse. `ok: false` means the store threw;
|
||||
* any value other than 'true' reads as expanded.
|
||||
*/
|
||||
function loadFocusCollapsed(storage) {
|
||||
try {
|
||||
return { collapsed: storage.getItem(FOCUS_COLLAPSED_STORAGE_KEY) === 'true', ok: true };
|
||||
} catch (_error) {
|
||||
return { collapsed: false, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function saveFocusCollapsed(storage, collapsed) {
|
||||
try {
|
||||
storage.setItem(FOCUS_COLLAPSED_STORAGE_KEY, collapsed === true ? 'true' : 'false');
|
||||
return { collapsed: collapsed === true, ok: true };
|
||||
} catch (_error) {
|
||||
return { collapsed: false, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The refs pinned to the Focus section that this browser can draw: focused,
|
||||
* a live session or an open web tab, each once, in stored order (groups in
|
||||
* order, then Ungrouped). Works with or without named groups, so Focus also
|
||||
* shows above the flat rail. A ref whose session closed or whose web tab is
|
||||
* not open here simply drops out; the server prunes deleted refs on its own.
|
||||
*/
|
||||
function focusRefs(layoutInput, options = {}) {
|
||||
if (!layoutInput) return [];
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
const live = new Set(asIds(options.liveSessionIds));
|
||||
const open = new Set(asIds(options.openWebviewIds));
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const ref of [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]) {
|
||||
if (ref.focus !== true) continue;
|
||||
if (!(ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id))) continue;
|
||||
const key = refKey(ref);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({ kind: ref.kind, id: ref.id });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function saveCollapsedGroupIds(storage, groupIds) {
|
||||
const ids = stableIds(groupIds);
|
||||
try {
|
||||
@@ -137,11 +192,13 @@
|
||||
* parent, so a spawned session appears in its parent's group at once instead
|
||||
* of jumping there from Ungrouped once the layout re-read lands.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs }}
|
||||
* null when the layout has no groups: the caller renders the flat rail
|
||||
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
|
||||
* collapse hides (`hidden`); `sectionByRef` maps every placed row
|
||||
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
|
||||
* `focusRefs` is focusRefs() for the same options: shortcuts, never rows,
|
||||
* so they are in none of the other fields.
|
||||
*/
|
||||
function project(layoutInput, options = {}) {
|
||||
if (!layoutInput) return null;
|
||||
@@ -207,7 +264,7 @@
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) };
|
||||
}
|
||||
|
||||
const ALERT_RANK = { action: 2, idle: 1 };
|
||||
@@ -603,6 +660,17 @@
|
||||
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
|
||||
return layout;
|
||||
}
|
||||
case 'setFocus': {
|
||||
// Pin or unpin one row. It never moves: Focus draws a shortcut, and the
|
||||
// row stays in its group. Idempotent, so a replay after a 409 is safe.
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
if (typeof op.focused !== 'boolean') editError('invalid Focus state');
|
||||
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(op.ref));
|
||||
if (!location) editError('unknown row');
|
||||
if (op.focused) location.ref.focus = true;
|
||||
else delete location.ref.focus;
|
||||
return layout;
|
||||
}
|
||||
case 'moveRef': {
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
const targetKey = refKey(op.ref);
|
||||
@@ -853,6 +921,9 @@
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
focusRefs,
|
||||
loadFocusCollapsed,
|
||||
saveFocusCollapsed,
|
||||
MAX_GROUPS,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -327,7 +327,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const actions = [
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
|
||||
@@ -325,6 +325,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
oncontextmenu="app.handleWebviewTabContextMenu(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
||||
@@ -347,6 +348,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Right-click (or the menu key) on a web tab in the vertical rail opens its
|
||||
* actions (Focus, group moves, settings), the flat rail included, where it is
|
||||
* the only way in. Everywhere else the browser keeps its own menu.
|
||||
*/
|
||||
handleWebviewTabContextMenu(event, id) {
|
||||
if (!this._tabLayoutEditable?.()) return false;
|
||||
return this.openTabWebviewMenu?.(event, id);
|
||||
},
|
||||
|
||||
/** Mark exactly one tab active across BOTH tab kinds. */
|
||||
_updateActiveWebviewTab() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -359,6 +370,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
this._syncTabTreeSelection?.(container);
|
||||
this._renderTabFocus?.();
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @fileoverview The Focus flag in the browser's tab-layout mirror (tab-layout-browser.js).
|
||||
*
|
||||
* The browser writes WHOLE layouts back (`PUT /api/tab-layout`), so any field
|
||||
* its defensive copy drops is erased on the next save, whoever made it. These
|
||||
* tests pin that `focus: true` is carried like `placement`, that `setFocus` is a
|
||||
* named operation that replays (and drops cleanly) through the edit coordinator,
|
||||
* that a 409 rebase keeps both our flag and one set elsewhere, that the focused
|
||||
* refs are exposed for rendering (with or without named groups), and the
|
||||
* per-device collapse storage.
|
||||
*
|
||||
* Port: none (vm-loaded module).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const SOURCE = readFileSync(join(process.cwd(), 'src/web/public/tab-layout-browser.js'), 'utf8');
|
||||
|
||||
type Ref = { kind: 'session' | 'webview'; id: string; placement?: 'manual'; focus?: true };
|
||||
type Layout = {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
groups: Array<{ id: string; name: string; refs: Ref[] }>;
|
||||
ungrouped: Ref[];
|
||||
};
|
||||
|
||||
function loadHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {}, setTimeout, clearTimeout });
|
||||
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
|
||||
return (context.window as any).CodemanTabLayout;
|
||||
}
|
||||
|
||||
const s = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'session', id, ...extra });
|
||||
const w = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'webview', id, ...extra });
|
||||
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
const base = (version = 5): Layout => ({
|
||||
version,
|
||||
updatedAt: '2026-10-09T00:00:00.000Z',
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core', refs: [s('a', { focus: true }), s('b')] },
|
||||
{ id: 'g2', name: 'Ops', refs: [w('web', { focus: true })] },
|
||||
],
|
||||
ungrouped: [s('c', { placement: 'manual', focus: true }), s('d')],
|
||||
});
|
||||
const focused = (layout: Layout) =>
|
||||
[...layout.groups.flatMap((g) => g.refs), ...layout.ungrouped]
|
||||
.filter((ref) => ref.focus === true)
|
||||
.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
describe('the defensive copy keeps focus', () => {
|
||||
const h = loadHelper();
|
||||
|
||||
it('normalizeLayout keeps focus: true on session and web refs, next to placement', () => {
|
||||
const copy = plain(h.normalizeLayout(base()));
|
||||
expect(copy.groups[0].refs[0]).toEqual({ kind: 'session', id: 'a', focus: true });
|
||||
expect(copy.groups[1].refs[0]).toEqual({ kind: 'webview', id: 'web', focus: true });
|
||||
expect(copy.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual', focus: true });
|
||||
expect(copy.ungrouped[1]).toEqual({ kind: 'session', id: 'd' });
|
||||
});
|
||||
|
||||
it('drops anything but a literal true (the server only stores true)', () => {
|
||||
const copy = plain(
|
||||
h.normalizeLayout({
|
||||
...base(),
|
||||
ungrouped: [s('c', { focus: 'yes' as never }), s('d', { focus: false as never })],
|
||||
})
|
||||
);
|
||||
expect(copy.ungrouped).toEqual([
|
||||
{ kind: 'session', id: 'c' },
|
||||
{ kind: 'session', id: 'd' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('every existing operation keeps the flag on the refs it touches', () => {
|
||||
let layout = base();
|
||||
layout = h.applyOperation(layout, { type: 'renameGroup', groupId: 'g1', name: 'Front' });
|
||||
layout = h.applyOperation(layout, { type: 'reorderGroup', groupId: 'g2', index: 0 });
|
||||
layout = h.applyOperation(layout, { type: 'moveRef', ref: s('a'), groupId: 'g2', index: 1, parents: {} });
|
||||
expect(plain(layout).groups[0].refs).toEqual([w('web', { focus: true }), s('a', { focus: true })]);
|
||||
layout = h.applyOperation(layout, { type: 'deleteGroup', groupId: 'g2' });
|
||||
expect(focused(plain(layout))).toEqual(['session:c', 'webview:web', 'session:a']);
|
||||
});
|
||||
|
||||
it('a moved lineage block keeps each member flag, and the head turns manual', () => {
|
||||
const layout: Layout = {
|
||||
...base(),
|
||||
groups: [{ id: 'g1', name: 'Core', refs: [s('p', { focus: true }), s('kid', { focus: true })] }],
|
||||
ungrouped: [],
|
||||
};
|
||||
const moved = plain(
|
||||
h.applyOperation(layout, { type: 'moveRef', ref: s('p'), groupId: null, parents: { kid: 'p', p: 'gp' } })
|
||||
);
|
||||
expect(moved.ungrouped).toEqual([s('p', { focus: true, placement: 'manual' }), s('kid', { focus: true })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFocus operation', () => {
|
||||
const h = loadHelper();
|
||||
|
||||
it('adds and removes the flag on one ref and moves nothing', () => {
|
||||
const added = plain(h.applyOperation(base(), { type: 'setFocus', ref: s('d'), focused: true }));
|
||||
expect(focused(added)).toEqual(['session:a', 'webview:web', 'session:c', 'session:d']);
|
||||
const removed = plain(h.applyOperation(added, { type: 'setFocus', ref: s('c'), focused: false }));
|
||||
expect(removed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual' });
|
||||
expect(removed.groups.map((g: any) => g.refs.map((r: Ref) => r.id))).toEqual([['a', 'b'], ['web']]);
|
||||
expect(h.contentKey(h.applyOperation(added, { type: 'setFocus', ref: s('d'), focused: true }))).toBe(
|
||||
h.contentKey(added)
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an unknown ref, a bad ref and a non-boolean state (so a replay drops it)', () => {
|
||||
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: s('gone'), focused: true })).toThrow(/unknown row/);
|
||||
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: { kind: 'x', id: 'a' }, focused: true })).toThrow();
|
||||
expect(() => h.applyOperation(base(), { type: 'setFocus', ref: s('a'), focused: 'yes' })).toThrow(/Focus/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit coordinator with focus', () => {
|
||||
const h = loadHelper();
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
function controlledPut() {
|
||||
const calls: Array<{ request: any; resolve: (value: any) => void }> = [];
|
||||
const put = vi.fn((request: any) => new Promise((resolve) => calls.push({ request: plain(request), resolve })));
|
||||
return { put, calls };
|
||||
}
|
||||
function makeEditor(put: any, extra: Record<string, unknown> = {}) {
|
||||
const applied: any[] = [];
|
||||
const editor = h.createEditCoordinator({
|
||||
initialLayout: base(),
|
||||
put,
|
||||
applyLayout: (layout: any, meta: any) => applied.push({ layout: plain(layout), meta }),
|
||||
schedule: (fn: () => void) => setTimeout(fn, 0),
|
||||
cancel: (handle: any) => clearTimeout(handle),
|
||||
...extra,
|
||||
});
|
||||
return { editor, applied };
|
||||
}
|
||||
|
||||
it('sends setFocus in the whole-layout PUT, and an unrelated edit keeps every other flag', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor } = makeEditor(put);
|
||||
editor.enqueue({ type: 'setFocus', ref: s('b'), focused: true });
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Web' });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(focused(calls[0].request.layout)).toEqual(['session:a', 'session:b', 'webview:web', 'session:c']);
|
||||
});
|
||||
|
||||
it('replays setFocus onto the 409 layout and keeps a flag set elsewhere', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor, applied } = makeEditor(put);
|
||||
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
|
||||
await settle();
|
||||
// Elsewhere: someone unpinned `a`, pinned `b` and renamed Core (version 9).
|
||||
const server: Layout = {
|
||||
...base(9),
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Front', refs: [s('a'), s('b', { focus: true })] },
|
||||
{ id: 'g2', name: 'Ops', refs: [w('web', { focus: true })] },
|
||||
],
|
||||
};
|
||||
calls[0].resolve({ ok: false, status: 409, layout: server });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].request.baseVersion).toBe(9);
|
||||
expect(calls[1].request.layout.groups[0].name).toBe('Front');
|
||||
expect(focused(calls[1].request.layout)).toEqual(['session:b', 'webview:web', 'session:c', 'session:d']);
|
||||
calls[1].resolve({ ok: true, status: 200, layout: { ...calls[1].request.layout, version: 10 } });
|
||||
await settle();
|
||||
expect(focused(applied.at(-1).layout)).toEqual(['session:b', 'webview:web', 'session:c', 'session:d']);
|
||||
expect(editor.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a setFocus whose ref vanished in the conflict, and reports it once', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const reportError = vi.fn();
|
||||
const { editor, applied } = makeEditor(put, { reportError });
|
||||
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
|
||||
await settle();
|
||||
calls[0].resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
layout: { ...base(9), ungrouped: [s('c', { placement: 'manual', focus: true })] },
|
||||
});
|
||||
await settle();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(reportError).toHaveBeenCalledTimes(1);
|
||||
expect(focused(applied.at(-1).layout)).toEqual(['session:a', 'webview:web', 'session:c']);
|
||||
});
|
||||
|
||||
it('adopting an external layout keeps its flags under a pending setFocus', () => {
|
||||
const { editor } = makeEditor(() => new Promise(() => undefined), { schedule: () => 1, cancel: () => undefined });
|
||||
editor.enqueue({ type: 'setFocus', ref: s('d'), focused: true });
|
||||
const external = { ...base(7), ungrouped: [s('c', { placement: 'manual' }), s('d')] };
|
||||
expect(editor.adoptExternal(external)).toBe(true);
|
||||
expect(focused(plain(editor.getLayout()))).toEqual(['session:a', 'webview:web', 'session:d']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focused refs for the rail', () => {
|
||||
const h = loadHelper();
|
||||
const options = { liveSessionIds: ['a', 'b', 'c', 'd'], openWebviewIds: ['web'] };
|
||||
|
||||
it('focusRefs lists renderable focused refs in stored order, with or without groups', () => {
|
||||
expect(plain(h.focusRefs(base(), options))).toEqual([s('a'), w('web'), s('c')]);
|
||||
const flat = { ...base(), groups: [], ungrouped: [s('d', { focus: true }), s('c'), w('web', { focus: true })] };
|
||||
expect(plain(h.focusRefs(flat, options))).toEqual([s('d'), w('web')]);
|
||||
// The flat layout still projects to null: the rail itself stays the flat list.
|
||||
expect(h.project(flat, options)).toBeNull();
|
||||
});
|
||||
|
||||
it('a gone session or a closed web tab drops out of Focus harmlessly', () => {
|
||||
expect(plain(h.focusRefs(base(), { liveSessionIds: ['b', 'c'], openWebviewIds: [] }))).toEqual([s('c')]);
|
||||
expect(plain(h.focusRefs(null, options))).toEqual([]);
|
||||
expect(
|
||||
plain(
|
||||
h.focusRefs({ ...base(), ungrouped: [s('c', { focus: true }), s('c', { focus: true })], groups: [] }, options)
|
||||
)
|
||||
).toEqual([s('c')]);
|
||||
});
|
||||
|
||||
it('project() exposes the same focusRefs on a grouped layout', () => {
|
||||
const projection = h.project(base(), options);
|
||||
expect(plain(projection.focusRefs)).toEqual([s('a'), w('web'), s('c')]);
|
||||
// Shortcuts are not rows: the grouped rows are exactly what they were.
|
||||
expect(plain(projection.visibleRefs)).toEqual([s('a'), s('b'), w('web'), s('c'), s('d')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus collapse is per-device storage', () => {
|
||||
const h = loadHelper();
|
||||
const memory = () => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
data,
|
||||
getItem: (key: string) => (data.has(key) ? data.get(key)! : null),
|
||||
setItem: (key: string, value: string) => void data.set(key, value),
|
||||
};
|
||||
};
|
||||
|
||||
it('round-trips under its own key and never touches the group collapse list', () => {
|
||||
const storage = memory();
|
||||
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: false, ok: true });
|
||||
expect(h.saveFocusCollapsed(storage, true)).toEqual({ collapsed: true, ok: true });
|
||||
expect(storage.data.get('codeman:tab-focus-collapsed')).toBe('true');
|
||||
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: true, ok: true });
|
||||
expect(storage.data.has('codeman:tab-groups-collapsed')).toBe(false);
|
||||
// Group collapse GC (ids of groups that no longer exist) leaves it alone.
|
||||
h.loadCollapsedGroupIds(storage, []);
|
||||
expect(h.loadFocusCollapsed(storage).collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('a junk value reads as expanded, and a throwing store reports failure', () => {
|
||||
const storage = memory();
|
||||
storage.setItem('codeman:tab-focus-collapsed', '{nope');
|
||||
expect(h.loadFocusCollapsed(storage)).toEqual({ collapsed: false, ok: true });
|
||||
const broken = {
|
||||
getItem: () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('denied');
|
||||
},
|
||||
};
|
||||
expect(h.loadFocusCollapsed(broken)).toEqual({ collapsed: false, ok: false });
|
||||
expect(h.saveFocusCollapsed(broken, true)).toEqual({ collapsed: false, ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* @fileoverview The Focus section of the vertical rail, driven through the shipping CodemanApp.
|
||||
*
|
||||
* A ref flagged `focus: true` gets a SHORTCUT in a "Focus" section pinned above
|
||||
* the rail's list; the row itself stays in its group. Pinned here:
|
||||
* - the section is its own element in #tabRail, above #sessionTabs, on the
|
||||
* grouped AND the flat rail, and absent off the vertical rail;
|
||||
* - shortcuts are not rows: no `.session-tab`, no Alt+N badge, no tree item,
|
||||
* so the badges, the tree's single tab stop, the row count and drag are
|
||||
* exactly what they were, and no id is duplicated;
|
||||
* - a click selects the tab; the selected tab's shortcut is aria-current;
|
||||
* - "Add to Focus" / "Remove from Focus" in the row and web-tab menus send
|
||||
* one `setFocus` through the edit coordinator (whole-layout PUT);
|
||||
* - per-device collapse, a list keyboard model with one tab stop, a gone
|
||||
* session dropping out, and a flag changed elsewhere repainting the section;
|
||||
* - the new strings translate to zh-CN.
|
||||
*
|
||||
* Port: none (JSDOM, stubbed fetch).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
type Ref = { kind: 'session' | 'webview'; id: string; placement?: 'manual'; focus?: true };
|
||||
type Layout = {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
groups: Array<{ id: string; name: string; refs: Ref[] }>;
|
||||
ungrouped: Ref[];
|
||||
};
|
||||
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
let win: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
win = dom.window;
|
||||
document = win.document;
|
||||
win.setInterval = () => 0;
|
||||
win.requestAnimationFrame = () => 0;
|
||||
win.CSS = { escape: (value: string) => value };
|
||||
win.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\n' +
|
||||
read('tab-rail-resize.js') +
|
||||
'\n' +
|
||||
read('api-client.js') +
|
||||
'\n' +
|
||||
read('webview-tabs.js') +
|
||||
'\n;window.__FocusCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = win.__FocusCodemanApp;
|
||||
});
|
||||
|
||||
const s = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'session', id, ...extra });
|
||||
const w = (id: string, extra: Partial<Ref> = {}): Ref => ({ kind: 'webview', id, ...extra });
|
||||
|
||||
const grouped = (version = 8): Layout => ({
|
||||
version,
|
||||
updatedAt: '2026-10-09T00:00:00.000Z',
|
||||
groups: [
|
||||
{ id: 'gx', name: 'Core', refs: [s('s2', { focus: true }), w('w1', { focus: true })] },
|
||||
{ id: 'gy', name: 'Later', refs: [] },
|
||||
],
|
||||
ungrouped: [s('s1'), s('s3', { focus: true })],
|
||||
});
|
||||
const flat = (version = 8): Layout => ({
|
||||
version,
|
||||
updatedAt: '2026-10-09T00:00:00.000Z',
|
||||
groups: [],
|
||||
ungrouped: [s('s1'), s('s2', { focus: true }), s('s3'), w('w1')],
|
||||
});
|
||||
|
||||
function installFetch() {
|
||||
const puts: any[] = [];
|
||||
win.fetch = vi.fn(async (_url: string, init: any) => {
|
||||
const body = init?.body ? JSON.parse(init.body) : null;
|
||||
if (init?.method === 'PUT') puts.push(body);
|
||||
const payload = { success: true, data: { layout: { ...body?.layout, version: (body?.baseVersion ?? 0) + 1 } } };
|
||||
return { ok: true, status: 200, json: async () => payload };
|
||||
});
|
||||
return puts;
|
||||
}
|
||||
|
||||
function makeApp(layout: Layout | null = grouped(), orientation = 'vertical') {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
document.documentElement.setAttribute('data-tab-orientation', orientation);
|
||||
document.documentElement.dataset.tabRailSort = 'manual';
|
||||
document.body.innerHTML =
|
||||
'<aside class="tab-rail" id="tabRail"><div id="tabRailResizeHandle"></div>' +
|
||||
'<div id="sessionTabs" class="session-tabs" role="tablist" aria-label="Session tabs"></div></aside>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'One', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'Two <b>', status: 'busy' }],
|
||||
['s3', { id: 's3', name: 'Three', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['s1', 's2', 's3'];
|
||||
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
|
||||
app.webviewOrder = ['w1'];
|
||||
app.activeSessionId = 's2';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._tabCollapseStorageFailed = false;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = vi.fn();
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app._refreshMobileOverviewIfVisible = () => {};
|
||||
app._refreshHomeSessionsIfVisible = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app._startSidebarRichClock = () => {};
|
||||
app._stopSidebarRichClock = () => {};
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.openSessionOptions = vi.fn();
|
||||
app.requestCloseSession = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
app.openWebview = vi.fn();
|
||||
app.showWebviewModal = vi.fn();
|
||||
app.showToast = vi.fn();
|
||||
app._fullRenderSessionTabs();
|
||||
if (layout) app._applyTabLayout(layout);
|
||||
return app;
|
||||
}
|
||||
|
||||
const section = () => document.getElementById('tabFocus');
|
||||
const shortcuts = () => [...document.querySelectorAll<HTMLButtonElement>('#tabFocus .tab-focus-item')];
|
||||
const shortcutKeys = () => shortcuts().map((item) => item.dataset.focusKey);
|
||||
const rows = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.dataset.webviewId || tab.dataset.id
|
||||
);
|
||||
const badges = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
|
||||
(tab) => tab.querySelector('.tab-number')?.textContent ?? null
|
||||
);
|
||||
const menuLabels = () => [...document.querySelectorAll('.tab-rail-action-menu button')].map((b) => b.textContent);
|
||||
const clickMenu = (label: string) =>
|
||||
[...document.querySelectorAll<HTMLElement>('.tab-rail-action-menu button')]
|
||||
.find((b) => b.textContent === label)!
|
||||
.click();
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`#sessionTabs .session-tab[data-id="${id}"]`)!;
|
||||
const key = (target: Element, k: string) =>
|
||||
target.dispatchEvent(new win.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
const focusedIn = (layout: Layout) =>
|
||||
[...layout.groups.flatMap((g) => g.refs), ...layout.ungrouped]
|
||||
.filter((ref) => ref.focus === true)
|
||||
.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
beforeEach(() => {
|
||||
win.localStorage.clear();
|
||||
win.sessionStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false };
|
||||
installFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.tab-rail-action-menu').forEach((menu) => menu.remove());
|
||||
});
|
||||
|
||||
describe('the Focus section', () => {
|
||||
it('draws shortcuts in stored order in its own section above the grouped list', () => {
|
||||
makeApp();
|
||||
const host = section()!;
|
||||
expect(host.hidden).toBe(false);
|
||||
expect(host.parentElement!.id).toBe('tabRail');
|
||||
expect(host.nextElementSibling!.id).toBe('sessionTabs');
|
||||
expect(shortcutKeys()).toEqual(['session:s2', 'webview:w1', 'session:s3']);
|
||||
expect(shortcuts().map((item) => item.textContent)).toEqual(['Two <b>', 'Dashboard', 'Three']);
|
||||
expect(document.getElementById('tabFocusToggle')!.textContent).toBe('Focus3');
|
||||
expect(document.getElementById('tabFocusList')!.getAttribute('aria-labelledby')).toBe('tabFocusTitle');
|
||||
});
|
||||
|
||||
it('leaves the rows, Alt+N badges, tree and drag exactly as without Focus', () => {
|
||||
const plainApp = makeApp({
|
||||
...grouped(),
|
||||
groups: grouped().groups.map((g) => ({ ...g, refs: g.refs.map(({ focus: _f, ...ref }) => ref) })),
|
||||
ungrouped: [s('s1'), s('s3')],
|
||||
});
|
||||
const before = { rows: rows(), badges: badges(), html: document.getElementById('sessionTabs')!.innerHTML };
|
||||
expect(section()?.hidden ?? true).toBe(true);
|
||||
plainApp._tabLayoutEditor?.dispose();
|
||||
makeApp();
|
||||
expect(rows()).toEqual(before.rows);
|
||||
expect(badges()).toEqual(before.badges);
|
||||
expect(document.getElementById('sessionTabs')!.innerHTML).toBe(before.html);
|
||||
// Shortcuts are not rows and not tree items.
|
||||
expect(
|
||||
document.querySelectorAll('#tabFocus .session-tab, #tabFocus [role="treeitem"], #tabFocus .tab-number')
|
||||
).toHaveLength(0);
|
||||
expect(document.getElementById('sessionTabs')!.getAttribute('role')).toBe('tree');
|
||||
expect(document.querySelectorAll('#sessionTabs [role="treeitem"][tabindex="0"]')).toHaveLength(1);
|
||||
// One tab stop in the Focus list too, and no id appears twice on the page.
|
||||
expect(shortcuts().filter((item) => item.tabIndex === 0)).toHaveLength(1);
|
||||
const ids = [...document.querySelectorAll('[id]')].map((el) => el.id);
|
||||
expect(ids.length).toBe(new Set(ids).size);
|
||||
expect(shortcuts().every((item) => item.getAttribute('draggable') !== 'true')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows above the FLAT rail too, which stays a plain tablist', () => {
|
||||
makeApp(flat());
|
||||
expect(document.getElementById('sessionTabs')!.getAttribute('role')).toBe('tablist');
|
||||
expect(rows()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
expect(shortcutKeys()).toEqual(['session:s2']);
|
||||
expect(section()!.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('is absent off the vertical rail and with no layout', () => {
|
||||
makeApp(grouped(), 'horizontal');
|
||||
expect(section()?.hidden ?? true).toBe(true);
|
||||
expect(shortcuts()).toHaveLength(0);
|
||||
makeApp(null);
|
||||
expect(section()?.hidden ?? true).toBe(true);
|
||||
});
|
||||
|
||||
it('a click selects the tab, and the selected tab shortcut is aria-current', () => {
|
||||
const app = makeApp();
|
||||
expect(shortcuts().map((item) => item.getAttribute('aria-current'))).toEqual(['true', 'false', 'false']);
|
||||
shortcuts()[2].click();
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s3', { forceReload: true });
|
||||
shortcuts()[1].click();
|
||||
expect(app.openWebview).toHaveBeenCalledWith('w1');
|
||||
app.activeSessionId = 's3';
|
||||
app._updateActiveTabImmediate('s3');
|
||||
expect(shortcuts().map((item) => item.getAttribute('aria-current'))).toEqual(['false', 'false', 'true']);
|
||||
app.activeWebviewId = 'w1';
|
||||
app._updateActiveWebviewTab();
|
||||
expect(shortcuts().map((item) => item.getAttribute('aria-current'))).toEqual(['false', 'true', 'false']);
|
||||
});
|
||||
|
||||
it('carries the tab status and alert onto the shortcut', () => {
|
||||
const app = makeApp();
|
||||
expect(shortcuts()[0].querySelector('.tab-status')!.className).toBe('tab-status busy');
|
||||
app.tabAlerts.set('s3', 'action');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(shortcuts()[2].classList.contains('tab-alert-action')).toBe(true);
|
||||
});
|
||||
|
||||
it('a closed session drops out harmlessly, and the section hides when nothing is left', () => {
|
||||
const app = makeApp(flat());
|
||||
app.sessions.delete('s2');
|
||||
app.sessionOrder = ['s1', 's3'];
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(shortcuts()).toHaveLength(0);
|
||||
expect(section()!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('repaints when another device changes a flag (not a structural change)', () => {
|
||||
const app = makeApp();
|
||||
app._applyTabLayout({ ...grouped(9), ungrouped: [s('s1', { focus: true }), s('s3')] });
|
||||
expect(shortcutKeys()).toEqual(['session:s2', 'webview:w1', 'session:s1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding and removing', () => {
|
||||
it('row menu offers Add to Focus, and choosing it PUTs the whole layout with the flag', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp();
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
|
||||
expect(menuLabels()[1]).toBe('Add to Focus');
|
||||
clickMenu('Add to Focus');
|
||||
// Optimistic, and the row did not move.
|
||||
expect(shortcutKeys()).toEqual(['session:s2', 'webview:w1', 'session:s1', 'session:s3']);
|
||||
expect(rows()).toEqual(['s2', 'w1', 's1', 's3']);
|
||||
await flush();
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0].baseVersion).toBe(8);
|
||||
expect(focusedIn(puts[0].layout)).toEqual(['session:s2', 'webview:w1', 'session:s1', 'session:s3']);
|
||||
});
|
||||
|
||||
it('a focused row offers Remove from Focus', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp(flat());
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
|
||||
expect(menuLabels()).toEqual(['Session options', 'Remove from Focus', 'Move to new group', 'Close session']);
|
||||
clickMenu('Remove from Focus');
|
||||
await flush();
|
||||
expect(focusedIn(puts[0].layout)).toEqual([]);
|
||||
expect(section()!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('the strip menu (horizontal) has no Focus entry', () => {
|
||||
const app = makeApp(grouped(), 'horizontal');
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
|
||||
expect(menuLabels()).toEqual(['Session options', 'Close session']);
|
||||
});
|
||||
|
||||
it('a web tab: right-click in the rail opens its menu with the Focus entry; elsewhere the native menu stays', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp(flat());
|
||||
const web = document.querySelector<HTMLElement>('#sessionTabs .session-tab[data-webview-id="w1"]')!;
|
||||
// The row markup wires the handler (inline handlers do not run under JSDOM;
|
||||
// the Chromium test right-clicks for real).
|
||||
expect(web.getAttribute('oncontextmenu')).toBe('app.handleWebviewTabContextMenu(event, "w1")');
|
||||
const event = new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'currentTarget', { value: web });
|
||||
app.handleWebviewTabContextMenu(event, 'w1');
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(menuLabels()).toEqual(['Web tab settings', 'Add to Focus', 'Move to new group']);
|
||||
clickMenu('Add to Focus');
|
||||
await flush();
|
||||
expect(focusedIn(puts[0].layout)).toEqual(['session:s2', 'webview:w1']);
|
||||
|
||||
const strip = makeApp(flat(), 'horizontal');
|
||||
const native = new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
||||
expect(strip.handleWebviewTabContextMenu(native, 'w1')).toBe(false);
|
||||
expect(native.defaultPrevented).toBe(false);
|
||||
expect(document.querySelectorAll('.tab-rail-action-menu')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('right-click on a shortcut offers Remove from Focus only', async () => {
|
||||
const puts = installFetch();
|
||||
makeApp();
|
||||
shortcuts()[0].dispatchEvent(new win.MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
|
||||
expect(menuLabels()).toEqual(['Remove from Focus']);
|
||||
clickMenu('Remove from Focus');
|
||||
await flush();
|
||||
expect(focusedIn(puts[0].layout)).toEqual(['webview:w1', 'session:s3']);
|
||||
// The real row is still there, in its group.
|
||||
expect(row('s2').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapse and keyboard', () => {
|
||||
it('collapse is per-device, survives a reload, and is not a group id', () => {
|
||||
const app = makeApp();
|
||||
document.getElementById('tabFocusToggle')!.click();
|
||||
expect(document.getElementById('tabFocusList')!.hidden).toBe(true);
|
||||
expect(document.getElementById('tabFocusToggle')!.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(win.localStorage.getItem('codeman:tab-focus-collapsed')).toBe('true');
|
||||
expect(JSON.parse(win.localStorage.getItem('codeman:tab-groups-collapsed') || '[]')).toEqual([]);
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
makeApp();
|
||||
expect(document.getElementById('tabFocusList')!.hidden).toBe(true);
|
||||
document.getElementById('tabFocusToggle')!.click();
|
||||
expect(document.getElementById('tabFocusList')!.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('Up/Down/Home/End walk the shortcuts with one tab stop; the tree is untouched', () => {
|
||||
makeApp();
|
||||
const treeStop = document.querySelector('#sessionTabs [role="treeitem"][tabindex="0"]');
|
||||
shortcuts()[0].focus();
|
||||
key(shortcuts()[0], 'ArrowDown');
|
||||
expect(document.activeElement).toBe(shortcuts()[1]);
|
||||
key(shortcuts()[1], 'End');
|
||||
expect(document.activeElement).toBe(shortcuts()[2]);
|
||||
key(shortcuts()[2], 'ArrowDown');
|
||||
expect(document.activeElement).toBe(shortcuts()[0]);
|
||||
key(shortcuts()[0], 'ArrowUp');
|
||||
expect(document.activeElement).toBe(shortcuts()[2]);
|
||||
expect(shortcuts().map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
|
||||
expect(document.querySelector('#sessionTabs [role="treeitem"][tabindex="0"]')).toBe(treeStop);
|
||||
});
|
||||
|
||||
it('keeps keyboard focus on the same shortcut across a repaint', () => {
|
||||
const app = makeApp();
|
||||
shortcuts()[1].focus();
|
||||
app.tabAlerts.set('s2', 'idle');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(document.activeElement).toBe(shortcuts()[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zh-CN', () => {
|
||||
it('translates every Focus string, and leaves a shortcut name alone', () => {
|
||||
const I18N = read('i18n.js');
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as any).CodemanI18n;
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const strings = ['Focus', 'Add to Focus', 'Remove from Focus', 'Focus actions'];
|
||||
const untranslated = strings.filter((text) => api.t(text) === text || /[A-Za-z]/.test(api.t(text)));
|
||||
expect(untranslated).toEqual([]);
|
||||
// Each is in the dictionary exactly once (a repeated key silently wins).
|
||||
for (const text of strings) {
|
||||
const keyPattern = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
expect((I18N.match(new RegExp(`^\\s*(?:'${keyPattern}'|${keyPattern}):`, 'gm')) ?? []).length).toBe(1);
|
||||
}
|
||||
// The section in the live DOM: heading translated, the user's names not.
|
||||
makeApp();
|
||||
dom.window.document.body.innerHTML = section()!.outerHTML;
|
||||
api.start();
|
||||
const translated = dom.window.document.getElementById('tabFocusTitle')!.textContent;
|
||||
expect(translated).toBe('焦点');
|
||||
expect(
|
||||
[...dom.window.document.querySelectorAll('.tab-focus-item')].map((b: any) => b.getAttribute('aria-label'))
|
||||
).toEqual(['Two <b>', 'Dashboard', 'Three']);
|
||||
dom.window.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @fileoverview Real-Chromium coverage for the Focus section of the vertical rail.
|
||||
*
|
||||
* What DOM emulation cannot answer: a real right-click reaching the inline
|
||||
* handlers, the section actually painting above the list (layout, not just DOM
|
||||
* order), the browser's own Tab order across the Focus toggle, the Focus list
|
||||
* and the grouped tree (one stop each), and a pointer drag on a shortcut doing
|
||||
* nothing. The shipping app.js, tab-layout-browser.js, tab-rail-resize.js,
|
||||
* api-client.js, webview-tabs.js and styles.css are loaded into a page;
|
||||
* PUT /api/tab-layout is answered by a route that records bodies.
|
||||
*
|
||||
* Port: none (page.route on a fake origin, no server).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||
|
||||
const GROUPED = {
|
||||
version: 4,
|
||||
updatedAt: '2026-10-09T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'gx',
|
||||
name: 'Core',
|
||||
refs: [
|
||||
{ kind: 'session', id: 'two', focus: true },
|
||||
{ kind: 'webview', id: 'web' },
|
||||
],
|
||||
},
|
||||
{ id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 'three' }] },
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 'one' }],
|
||||
};
|
||||
const FLAT = {
|
||||
version: 4,
|
||||
updatedAt: '2026-10-09T00:00:00.000Z',
|
||||
groups: [],
|
||||
ungrouped: [
|
||||
{ kind: 'session', id: 'one' },
|
||||
{ kind: 'session', id: 'two', focus: true },
|
||||
{ kind: 'session', id: 'three' },
|
||||
{ kind: 'webview', id: 'web' },
|
||||
],
|
||||
};
|
||||
|
||||
const focusedIn = (layout: any): string[] =>
|
||||
[...layout.groups.flatMap((g: any) => g.refs), ...layout.ungrouped]
|
||||
.filter((ref: any) => ref.focus === true)
|
||||
.map((ref: any) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
describe('Focus section in Chromium', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let puts: any[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
||||
await page.route('http://codeman.test/', (route) =>
|
||||
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
|
||||
);
|
||||
await page.route('http://codeman.test/api/tab-layout', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== 'PUT') return route.fulfill({ status: 404, body: '' });
|
||||
const body = JSON.parse(request.postData() || '{}');
|
||||
puts.push(body);
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, data: { layout: { ...body.layout, version: body.baseVersion + 1 } } }),
|
||||
});
|
||||
});
|
||||
await page.goto('http://codeman.test/');
|
||||
await page.setContent(`<!doctype html>
|
||||
<html data-tab-orientation="vertical" data-tab-rail-sort="manual">
|
||||
<head><style>${read('styles.css')}</style></head>
|
||||
<body>
|
||||
<button id="before">before</button>
|
||||
<main class="main" style="width:100%;height:760px">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation" style="width:280px"></aside>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
await page.addScriptTag({
|
||||
content:
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
|
||||
});
|
||||
await page.addScriptTag({ content: read('tab-rail-resize.js') });
|
||||
await page.addScriptTag({ content: read('api-client.js') });
|
||||
await page.addScriptTag({ content: read('webview-tabs.js') });
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = Object.create(w.CodemanApp.prototype);
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['one', { id: 'one', name: 'One', status: 'idle' }],
|
||||
['two', { id: 'two', name: 'Two', status: 'busy' }],
|
||||
['three', { id: 'three', name: 'Three', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['one', 'two', 'three'];
|
||||
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
|
||||
app.webviewOrder = ['web'];
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => undefined;
|
||||
app.updateTabOverflowMode = () => undefined;
|
||||
app.updateConnectionLines = () => undefined;
|
||||
app._applyTabEntrances = () => undefined;
|
||||
app._scrollActiveTabIntoView = () => undefined;
|
||||
app.applySidebarFilter = () => undefined;
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app._startSidebarRichClock = () => undefined;
|
||||
app._stopSidebarRichClock = () => undefined;
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.showToast = () => undefined;
|
||||
app.showWebviewModal = () => undefined;
|
||||
app.selectSession = (id: string) => {
|
||||
w.__activation = `session:${id}`;
|
||||
};
|
||||
app.openWebview = (id: string) => {
|
||||
w.__activation = `webview:${id}`;
|
||||
};
|
||||
w.__setApp(app);
|
||||
w.__app = app;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => browser.close());
|
||||
|
||||
async function load(layout: unknown) {
|
||||
puts = [];
|
||||
await page.evaluate((next) => {
|
||||
const w = window as any;
|
||||
localStorage.clear();
|
||||
document.querySelectorAll('.tab-rail-action-menu').forEach((menu) => menu.remove());
|
||||
document.getElementById('sessionTabs')?.remove();
|
||||
document.getElementById('tabFocus')?.remove();
|
||||
document
|
||||
.getElementById('tabRail')!
|
||||
.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
|
||||
);
|
||||
w.__app._tabKeydownHandler = null;
|
||||
w.__app._tabLayoutEditor?.dispose();
|
||||
w.__app._tabLayoutEditor = null;
|
||||
w.__app.tabLayout = null;
|
||||
w.__app._tabFocusCollapsed = undefined;
|
||||
w.__app._lastTabFocusHtml = '';
|
||||
w.__app._tabFocusStopKey = undefined;
|
||||
w.__app.activeSessionId = 'one';
|
||||
w.__app.activeWebviewId = null;
|
||||
w.__app._lastTabGroupStructureKey = null;
|
||||
w.__app._fullRenderSessionTabs();
|
||||
w.__app._applyTabLayout(next);
|
||||
w.__activation = null;
|
||||
}, layout);
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
const settled = async () => {
|
||||
await page.waitForTimeout(80);
|
||||
await page.evaluate(() => new Promise((r) => setTimeout(r, 20)));
|
||||
};
|
||||
const box = async (selector: string) => (await page.locator(selector).boundingBox())!;
|
||||
|
||||
beforeEach(async () => load(GROUPED));
|
||||
|
||||
it('paints the section above the list, and a click on a shortcut selects its tab', async () => {
|
||||
const section = await box('#tabFocus');
|
||||
const list = await box('#sessionTabs');
|
||||
expect(section.height).toBeGreaterThan(20);
|
||||
expect(section.y + section.height).toBeLessThanOrEqual(list.y + 1);
|
||||
expect(await page.locator('#tabFocus .tab-focus-name').allTextContents()).toEqual(['Two']);
|
||||
// The real row is still in its group.
|
||||
expect(await page.locator('[data-tab-group-id="gx"] .session-tab[data-id="two"]').count()).toBe(1);
|
||||
await page.click('#tabFocus .tab-focus-item');
|
||||
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two');
|
||||
});
|
||||
|
||||
it('a real right-click on a web tab in the FLAT rail pins it, and its shortcut opens it', async () => {
|
||||
await load(FLAT);
|
||||
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
|
||||
await page.click('#sessionTabs .session-tab[data-webview-id="web"]', { button: 'right' });
|
||||
const labels = await page.locator('.tab-rail-action-menu button').allTextContents();
|
||||
expect(labels).toEqual(['Web tab settings', 'Add to Focus', 'Move to new group']);
|
||||
await page.click('.tab-rail-action-menu button:text-is("Add to Focus")');
|
||||
await settled();
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(focusedIn(puts[0].layout)).toEqual(['session:two', 'webview:web']);
|
||||
expect(await page.locator('#tabFocus .tab-focus-name').allTextContents()).toEqual(['Two', 'Web']);
|
||||
await page.click('#tabFocus .tab-focus-item[data-focus-key="webview:web"]');
|
||||
expect(await page.evaluate(() => (window as any).__activation)).toBe('webview:web');
|
||||
});
|
||||
|
||||
it('a real right-click on a shortcut removes it; the row stays', async () => {
|
||||
await page.click('#tabFocus .tab-focus-item', { button: 'right' });
|
||||
expect(await page.locator('.tab-rail-action-menu button').allTextContents()).toEqual(['Remove from Focus']);
|
||||
await page.click('.tab-rail-action-menu button:text-is("Remove from Focus")');
|
||||
await settled();
|
||||
expect(focusedIn(puts[0].layout)).toEqual([]);
|
||||
expect(await page.locator('#tabFocus').isHidden()).toBe(true);
|
||||
expect(await page.locator('[data-tab-group-id="gx"] .session-tab[data-id="two"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
it('Tab walks: Focus toggle, ONE Focus list stop, then ONE tree stop', async () => {
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
w.__app._applyTabLayout({
|
||||
...w.__app.tabLayout,
|
||||
version: 5,
|
||||
ungrouped: [{ kind: 'session', id: 'one', focus: true }],
|
||||
});
|
||||
});
|
||||
expect(await page.locator('#tabFocus .tab-focus-item').count()).toBe(2);
|
||||
await page.focus('#before');
|
||||
const stops: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
stops.push(
|
||||
await page.evaluate(() => {
|
||||
const el = document.activeElement as HTMLElement;
|
||||
return el.id || el.dataset.focusKey || el.dataset.tabGroupHeader || el.dataset.id || el.tagName;
|
||||
})
|
||||
);
|
||||
}
|
||||
// 'one' is the active session: its shortcut holds the list stop, its row the tree stop.
|
||||
expect(stops).toEqual(['tabFocusToggle', 'session:one', 'one']);
|
||||
await page.keyboard.down('Shift');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.up('Shift');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
expect(await page.evaluate(() => (document.activeElement as HTMLElement).dataset.focusKey)).toBe('session:two');
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two');
|
||||
});
|
||||
|
||||
it('the toggle collapses per device, and a drag from a shortcut moves nothing', async () => {
|
||||
await page.click('#tabFocusToggle');
|
||||
expect(await page.locator('#tabFocusList').isHidden()).toBe(true);
|
||||
expect(await page.evaluate(() => localStorage.getItem('codeman:tab-focus-collapsed'))).toBe('true');
|
||||
await page.click('#tabFocusToggle');
|
||||
const a = await box('#tabFocus .tab-focus-item');
|
||||
const b = await box('[data-tab-group-header="gy"]');
|
||||
await page.mouse.move(a.x + 20, a.y + a.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await settled();
|
||||
expect(puts).toHaveLength(0);
|
||||
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into').count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -627,13 +627,14 @@ describe('row actions in the vertical rail', () => {
|
||||
installFetch();
|
||||
const flat = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
|
||||
flat.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
|
||||
expect(menuLabels()).toEqual(['Session options', 'Move to new group', 'Close session']);
|
||||
expect(menuLabels()).toEqual(['Session options', 'Add to Focus', 'Move to new group', 'Close session']);
|
||||
flat.closeTabRailActionMenu();
|
||||
|
||||
const app = makeApp();
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
|
||||
expect(menuLabels()).toEqual([
|
||||
'Session options',
|
||||
'Add to Focus',
|
||||
'Move down',
|
||||
'Move to "Later"',
|
||||
'Move to Ungrouped',
|
||||
@@ -660,6 +661,7 @@ describe('row actions in the vertical rail', () => {
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
|
||||
expect(menuLabels()).toEqual([
|
||||
'Session options',
|
||||
'Add to Focus',
|
||||
'Move to "ungrouped"',
|
||||
'Move to Ungrouped',
|
||||
'Move to new group',
|
||||
@@ -736,6 +738,7 @@ describe('web tab rows', () => {
|
||||
key(web, 'F10', { shiftKey: true });
|
||||
expect(menuLabels()).toEqual([
|
||||
'Web tab settings',
|
||||
'Add to Focus',
|
||||
'Move up',
|
||||
'Move to "Later"',
|
||||
'Move to Ungrouped',
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* @fileoverview The Focus flag on owner tab layout refs, server side.
|
||||
*
|
||||
* A session or web-tab ref may carry `focus: true` (src/tab-layout.ts): the
|
||||
* browser then draws a shortcut to it in a pinned "Focus" section at the top of
|
||||
* the vertical rail, while the ref itself stays where it is stored. These tests
|
||||
* pin that the flag is parsed strictly, and that nothing on the server drops it:
|
||||
* normalization, the pure edit helpers, TabLayoutService (PUT, legacy order,
|
||||
* deletions, reconciliation), the legacy-order projection, and StateStore's
|
||||
* persistence across a reload. Multi-user owner scoping is unchanged.
|
||||
*
|
||||
* Port: none (Fastify inject, temp state file).
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StateStore } from '../src/state-store.js';
|
||||
import { applyLegacySessionRank } from '../src/tab-layout-legacy-order.js';
|
||||
import { normalizeOrMigrateOwnerTabLayout } from '../src/tab-layout-persistence.js';
|
||||
import { TabLayoutService } from '../src/tab-layout-service.js';
|
||||
import {
|
||||
deleteGroup,
|
||||
followParent,
|
||||
materializeOrphans,
|
||||
moveRef,
|
||||
normalizeTabLayout,
|
||||
setTabFocus,
|
||||
TabLayoutValidationError,
|
||||
validateTabLayout,
|
||||
type TabLayout,
|
||||
type TabRefMetadata,
|
||||
} from '../src/tab-layout.js';
|
||||
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
|
||||
import { registerTabLayoutRoutes } from '../src/web/routes/tab-layout-routes.js';
|
||||
|
||||
const at = '2026-10-09T00:00:00.000Z';
|
||||
const session = (id: string, extra: Record<string, unknown> = {}) => ({ kind: 'session' as const, id, ...extra });
|
||||
const webview = (id: string, extra: Record<string, unknown> = {}) => ({ kind: 'webview' as const, id, ...extra });
|
||||
|
||||
const meta = (
|
||||
id: string,
|
||||
order: number,
|
||||
extra: Partial<TabRefMetadata> = {},
|
||||
kind: 'session' | 'webview' = 'session'
|
||||
): TabRefMetadata => ({ kind, id, ownerValid: true, visible: true, order, ...extra });
|
||||
|
||||
/** Every ref that carries the flag, as `kind:id`, in stored order. */
|
||||
const focused = (layout: TabLayout): string[] =>
|
||||
[...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]
|
||||
.filter((ref) => ref.focus === true)
|
||||
.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
const base = (): TabLayout => ({
|
||||
version: 3,
|
||||
updatedAt: at,
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core', refs: [session('a', { focus: true }), webview('w', { focus: true })] },
|
||||
{ id: 'g2', name: 'Ops', refs: [session('b')] },
|
||||
],
|
||||
ungrouped: [session('c', { placement: 'manual', focus: true }), session('d')],
|
||||
});
|
||||
|
||||
const metadata: TabRefMetadata[] = [
|
||||
meta('a', 0),
|
||||
meta('b', 1),
|
||||
meta('c', 2),
|
||||
meta('d', 3),
|
||||
meta('w', 4, {}, 'webview'),
|
||||
];
|
||||
|
||||
describe('model: parsing the focus flag', () => {
|
||||
it('keeps focus: true on session and webview refs, beside placement', () => {
|
||||
const parsed = validateTabLayout(base());
|
||||
expect(parsed.groups[0].refs).toEqual([session('a', { focus: true }), webview('w', { focus: true })]);
|
||||
expect(parsed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual', focus: true });
|
||||
expect(parsed.ungrouped[1]).toEqual({ kind: 'session', id: 'd' });
|
||||
});
|
||||
|
||||
it.each([false, 'true', 1, null, {}])('rejects focus: %j (only true or absent is valid)', (value) => {
|
||||
const input = { ...base(), ungrouped: [session('d', { focus: value })] };
|
||||
expect(() => validateTabLayout(input)).toThrow(TabLayoutValidationError);
|
||||
expect(() => validateTabLayout(input)).toThrow(/focus/);
|
||||
});
|
||||
|
||||
it('a ref with no focus key gets no focus key back (absent, never false)', () => {
|
||||
const parsed = validateTabLayout({ ...base(), groups: [], ungrouped: [session('d')] });
|
||||
expect(Object.keys(parsed.ungrouped[0]).sort()).toEqual(['id', 'kind']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model: the flag survives every pure edit', () => {
|
||||
it('normalizeTabLayout keeps focus, including on lineage-reordered children', () => {
|
||||
const layout: TabLayout = {
|
||||
version: 1,
|
||||
updatedAt: at,
|
||||
groups: [],
|
||||
ungrouped: [session('child', { focus: true }), session('parent')],
|
||||
};
|
||||
const normalized = normalizeTabLayout(layout, [meta('parent', 0), meta('child', 1, { parentSessionId: 'parent' })]);
|
||||
// The child follows its parent now, and still carries the flag.
|
||||
expect(normalized.ungrouped).toEqual([session('parent'), session('child', { focus: true })]);
|
||||
});
|
||||
|
||||
it('normalizeTabLayout prunes a focused ref whose session is known to be gone', () => {
|
||||
const normalized = normalizeTabLayout(base(), [...metadata, meta('a', 0, { ownerValid: false })].slice(1));
|
||||
expect(focused(normalized)).toEqual(['webview:w', 'session:c']);
|
||||
});
|
||||
|
||||
it('moveRef across groups keeps the moved ref and its followers focused', () => {
|
||||
const withChild: TabLayout = {
|
||||
...base(),
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core', refs: [session('a', { focus: true }), session('kid', { focus: true })] },
|
||||
{ id: 'g2', name: 'Ops', refs: [session('b')] },
|
||||
],
|
||||
ungrouped: [],
|
||||
};
|
||||
const facts = [meta('a', 0), meta('kid', 1, { parentSessionId: 'a' }), meta('b', 2)];
|
||||
const moved = moveRef(withChild, session('a'), { groupId: 'g2', index: 1 }, facts);
|
||||
expect(moved.groups[1].refs).toEqual([
|
||||
session('b'),
|
||||
session('a', { focus: true }),
|
||||
session('kid', { focus: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('followParent clears manual placement but keeps focus', () => {
|
||||
const layout: TabLayout = {
|
||||
version: 1,
|
||||
updatedAt: at,
|
||||
groups: [],
|
||||
ungrouped: [session('parent'), session('other'), session('kid', { placement: 'manual', focus: true })],
|
||||
};
|
||||
const facts = [meta('parent', 0), meta('other', 1), meta('kid', 2, { parentSessionId: 'parent' })];
|
||||
const result = followParent(layout, session('kid'), facts);
|
||||
expect(result.ungrouped).toEqual([session('parent'), session('kid', { focus: true }), session('other')]);
|
||||
});
|
||||
|
||||
it('deleteGroup and materializeOrphans keep focus on the refs they move or pin', () => {
|
||||
expect(focused(deleteGroup(base(), 'g1'))).toEqual(['session:c', 'session:a', 'webview:w']);
|
||||
const orphaned = materializeOrphans(
|
||||
{ ...base(), ungrouped: [session('p'), session('kid', { focus: true })] },
|
||||
['p'],
|
||||
[...metadata, meta('p', 5), meta('kid', 6, { parentSessionId: 'p' })]
|
||||
);
|
||||
expect(orphaned.ungrouped).toEqual([session('kid', { placement: 'manual', focus: true })]);
|
||||
});
|
||||
|
||||
it('setTabFocus adds and removes the flag on one ref without moving anything', () => {
|
||||
const added = setTabFocus(base(), session('b'), true);
|
||||
expect(focused(added)).toEqual(['session:a', 'webview:w', 'session:b', 'session:c']);
|
||||
const removed = setTabFocus(added, session('c'), false);
|
||||
expect(removed.ungrouped[0]).toEqual({ kind: 'session', id: 'c', placement: 'manual' });
|
||||
expect(removed.groups.map((group) => group.refs.map((ref) => ref.id))).toEqual([['a', 'w'], ['b']]);
|
||||
// Idempotent both ways.
|
||||
expect(setTabFocus(added, session('b'), true)).toEqual(added);
|
||||
expect(setTabFocus(base(), session('d'), false)).toEqual(validateTabLayout(base()));
|
||||
expect(() => setTabFocus(base(), session('nope'), true)).toThrow(/unknown ref/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy order projection', () => {
|
||||
it('applyLegacySessionRank re-ranks focused sessions without dropping the flag', () => {
|
||||
const ranked = applyLegacySessionRank(base(), ['d', 'c', 'b', 'a'], metadata);
|
||||
expect(ranked.ungrouped).toEqual([session('d'), session('c', { placement: 'manual', focus: true })]);
|
||||
expect(focused(ranked)).toEqual(['session:a', 'webview:w', 'session:c']);
|
||||
});
|
||||
|
||||
it('migration of an existing layout keeps focus', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout({
|
||||
owner: '@single',
|
||||
layouts: { '@single': base() },
|
||||
sessionOrder: [],
|
||||
persistedSessions: [],
|
||||
liveSessions: ['a', 'b', 'c', 'd'].map((id, index) => ({ id, createdAt: index })),
|
||||
webviews: [{ id: 'w' }],
|
||||
updatedAt: at,
|
||||
});
|
||||
expect(focused(result.layout)).toEqual(['session:a', 'webview:w', 'session:c']);
|
||||
});
|
||||
});
|
||||
|
||||
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string };
|
||||
|
||||
function createService(
|
||||
options: {
|
||||
layouts?: Record<string, TabLayout>;
|
||||
live?: SessionFact[];
|
||||
webviews?: Array<{ id: string; owner?: string }>;
|
||||
} = {}
|
||||
) {
|
||||
const layouts = structuredClone(options.layouts ?? {});
|
||||
const order: string[] = [];
|
||||
const webviews = [...(options.webviews ?? [])];
|
||||
const live = new Map((options.live ?? []).map((record) => [record.id, record]));
|
||||
const store = {
|
||||
getTabLayout: (owner: string) => structuredClone(layouts[owner] ?? null),
|
||||
getTabLayouts: () => structuredClone(layouts),
|
||||
getSessions: () => ({}),
|
||||
getSessionOrder: () => [...order],
|
||||
commitTabLayoutProjection: (
|
||||
updates: Readonly<Record<string, TabLayout>>,
|
||||
project: (latest: readonly string[]) => readonly string[]
|
||||
) => {
|
||||
const projected = [...project([...order])];
|
||||
Object.assign(layouts, structuredClone(updates));
|
||||
order.splice(0, order.length, ...projected);
|
||||
return { layouts: structuredClone(updates), sessionOrder: [...order] };
|
||||
},
|
||||
};
|
||||
const service = new TabLayoutService({
|
||||
store: store as never,
|
||||
sessions: live as never,
|
||||
readWebviews: async () => webviews,
|
||||
broadcast: vi.fn(),
|
||||
broadcastSessionOrder: vi.fn(),
|
||||
now: () => at,
|
||||
});
|
||||
service.markRestorationComplete();
|
||||
return { service, layouts, live, webviews };
|
||||
}
|
||||
|
||||
const liveFacts = (owner?: string): SessionFact[] =>
|
||||
['a', 'b', 'c', 'd'].map((id, index) => ({ id, owner, createdAt: index }));
|
||||
|
||||
describe('TabLayoutService keeps focus', () => {
|
||||
it('stores focus from a PUT and returns it from GET', async () => {
|
||||
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
|
||||
const current = await h.service.get('@single');
|
||||
expect(focused(current)).toEqual(['session:a', 'webview:w', 'session:c']);
|
||||
const result = await h.service.put('@single', setTabFocus(current, session('d'), true), current.version);
|
||||
expect(result.status).toBe('updated');
|
||||
expect(focused(await h.service.get('@single'))).toEqual(['session:a', 'webview:w', 'session:c', 'session:d']);
|
||||
});
|
||||
|
||||
it('keeps focus on a ref a PUT moves into another group', async () => {
|
||||
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
|
||||
const current = await h.service.get('@single');
|
||||
// The browser's moveRef: `a` (focused) leaves g1 for the end of g2.
|
||||
const desired: TabLayout = {
|
||||
...current,
|
||||
groups: [
|
||||
{ ...current.groups[0], refs: [webview('w', { focus: true })] },
|
||||
{ ...current.groups[1], refs: [session('b'), session('a', { focus: true })] },
|
||||
],
|
||||
};
|
||||
const result = await h.service.put('@single', desired, current.version);
|
||||
expect(result.layout.groups[1].refs).toEqual([session('b'), session('a', { focus: true })]);
|
||||
expect(focused(h.layouts['@single'])).toEqual(['webview:w', 'session:a', 'session:c']);
|
||||
});
|
||||
|
||||
it('keeps focus through a legacy session-order PUT (reordering)', async () => {
|
||||
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
|
||||
await h.service.putLegacyOrder({ owner: '@single', isAdmin: true }, ['d', 'c', 'b', 'a']);
|
||||
expect(focused(h.layouts['@single'])).toEqual(['session:a', 'webview:w', 'session:c']);
|
||||
expect(h.layouts['@single'].ungrouped.map((ref) => ref.id)).toEqual(['d', 'c']);
|
||||
});
|
||||
|
||||
it('a deleted session takes its focus flag with it and leaves the others alone', async () => {
|
||||
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
|
||||
await h.service.get('@single');
|
||||
await h.service.runSessionDeletion([{ id: 'a' }], async () => {
|
||||
h.live.delete('a');
|
||||
});
|
||||
expect(focused(h.layouts['@single'])).toEqual(['webview:w', 'session:c']);
|
||||
});
|
||||
|
||||
it('a deleted web tab takes its focus flag with it', async () => {
|
||||
const h = createService({ layouts: { '@single': base() }, live: liveFacts(), webviews: [{ id: 'w' }] });
|
||||
await h.service.get('@single');
|
||||
h.webviews.splice(0);
|
||||
await h.service.webviewDeleted('@single', 'w');
|
||||
expect(focused(h.layouts['@single'])).toEqual(['session:a', 'session:c']);
|
||||
});
|
||||
|
||||
it('multi-user: focus is per owner, and a PUT naming a foreign ref is still refused', async () => {
|
||||
const aliceLayout: TabLayout = {
|
||||
version: 1,
|
||||
updatedAt: at,
|
||||
groups: [],
|
||||
ungrouped: [session('a', { focus: true })],
|
||||
};
|
||||
const h = createService({
|
||||
layouts: { alice: aliceLayout },
|
||||
live: [
|
||||
{ id: 'a', owner: 'alice', createdAt: 0 },
|
||||
{ id: 'b', owner: 'bob', createdAt: 1 },
|
||||
],
|
||||
});
|
||||
const bob = await h.service.get('bob');
|
||||
expect(focused(bob)).toEqual([]);
|
||||
expect(focused(await h.service.get('alice'))).toEqual(['session:a']);
|
||||
await expect(
|
||||
h.service.put('bob', { ...bob, ungrouped: [session('b'), session('a', { focus: true })] }, bob.version)
|
||||
).rejects.toThrow(/not owned/);
|
||||
await h.service.put('bob', setTabFocus(bob, session('b'), true), bob.version);
|
||||
expect(focused(h.layouts.bob)).toEqual(['session:b']);
|
||||
expect(focused(h.layouts.alice)).toEqual(['session:a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence and the HTTP route', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('PUT /api/tab-layout with focus round-trips through GET and a StateStore reload', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-focus-'));
|
||||
dirs.push(dir);
|
||||
const file = join(dir, 'state.json');
|
||||
writeFileSync(file, JSON.stringify({}));
|
||||
const store = new StateStore(file);
|
||||
const live = new Map(liveFacts().map((record) => [record.id, record]));
|
||||
const service = new TabLayoutService({
|
||||
store,
|
||||
sessions: live,
|
||||
readWebviews: async () => [],
|
||||
broadcast: () => undefined,
|
||||
broadcastSessionOrder: () => undefined,
|
||||
now: () => at,
|
||||
});
|
||||
service.markRestorationComplete();
|
||||
const app = Fastify({ logger: false });
|
||||
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '0');
|
||||
try {
|
||||
const read = await app.inject({ method: 'GET', url: '/api/tab-layout' });
|
||||
const layout = read.json().data.layout as TabLayout;
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: { baseVersion: layout.version, layout: setTabFocus(layout, session('b'), true) },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(focused(put.json().data.layout)).toEqual(['session:b']);
|
||||
const refused = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: {
|
||||
baseVersion: put.json().data.layout.version,
|
||||
layout: { ...put.json().data.layout, ungrouped: [session('a', { focus: false })] },
|
||||
},
|
||||
});
|
||||
expect(refused.statusCode).toBe(400);
|
||||
store.saveNow();
|
||||
} finally {
|
||||
await app.close();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
// A fresh store over the same file: what a restart would read.
|
||||
expect(focused(new StateStore(file).getTabLayout('@single')!)).toEqual(['session:b']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user