mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
feat(tabs): edit groups in the vertical rail
The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.
Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
reorderGroup, moveRef) applied to the rail at once, mirroring the server
model: a moved session takes the sessions that still follow it, and a
hand-moved child is marked placement 'manual'. normalizeLayout now keeps
placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
made in the same turn share a write; edits made while one is in flight go
out on the version it returns. A 409 replays the operations onto the
layout the server returned and retries (bounded); an operation that no
longer applies is dropped and reported. A 400 re-reads first; any other
failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
that changes nothing.
Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
Ungrouped and Move to new group in the vertical rail. Before the first
group exists it offers only "Move to new group", which is how a flat rail
becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
a hover glyph (a non-focusable aria-hidden span, so the treeitem still
holds no interactive child): Rename, New group, Move group up/down,
Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
back to its row or header), a pointer outside, Tab, focus leaving it, a
resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
the current editor releases the render guard. Enter or blur commits,
Escape cancels, IME composition keys are left to the IME, and the label
becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
row or into a group, a header drag reorders groups. Escape cancels; the
click that ends a drag neither selects nor toggles. The flat rail and the
header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
once it settles; a read otherwise rebases unsaved edits. On pagehide,
unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.
Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.
Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
This commit is contained in:
@@ -257,7 +257,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 only READS 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. ⚠️ 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`. No frontend WRITES the layout yet. ⚠️ `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. ⚠️ 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. ⚠️ `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)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/tab-layout-editing.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
|
||||
@@ -296,14 +296,16 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
|
||||
**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs.
|
||||
|
||||
⚠️ **The frontend READS the layout; nothing writes it yet.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections. The rules that keep it safe:
|
||||
⚠️ **The frontend READS and EDITS the layout.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections and edits it. The rules that keep it safe:
|
||||
|
||||
- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing.
|
||||
- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`).
|
||||
- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row.
|
||||
- **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible.
|
||||
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
|
||||
- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||
- **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device.
|
||||
- **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads first; anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage; after reload they replay onto the fresh layout, which is a no-op when the keepalive landed.
|
||||
- **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`.
|
||||
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
|
||||
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard).
|
||||
|
||||
|
||||
+603
-9
@@ -866,6 +866,8 @@ class CodemanApp {
|
||||
// Flush the durable queue synchronously when the page is hidden/closed —
|
||||
// debounced persistence may have a pending write we mustn't lose on reload.
|
||||
window.addEventListener('pagehide', () => this._persistReliableNow());
|
||||
// Tab group edits not yet confirmed by the server survive a reload.
|
||||
window.addEventListener('pagehide', () => this._persistPendingTabLayoutEdits());
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') this._persistReliableNow();
|
||||
// A background tab's timers are throttled, so the 5s watchdog may not
|
||||
@@ -1267,6 +1269,16 @@ class CodemanApp {
|
||||
|
||||
// Escape - close panels and modals (different logic: no preventDefault, no return)
|
||||
if (e.key === 'Escape') {
|
||||
// An open group menu (or a grouped-rail drag) owns this Escape: close
|
||||
// just that, not every panel behind it.
|
||||
if (this._tabGroupMenu && this._tabGroupMenuKeydown) {
|
||||
this._tabGroupMenuKeydown(e);
|
||||
return;
|
||||
}
|
||||
if (this._tabLayoutDrag?.active && this._tabLayoutDragKeydown) {
|
||||
this._tabLayoutDragKeydown(e);
|
||||
return;
|
||||
}
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
@@ -5695,6 +5707,8 @@ class CodemanApp {
|
||||
_fullRenderSessionTabs() {
|
||||
this.closeTabRailActionMenu?.();
|
||||
if (this._inlineRenameActive) return;
|
||||
// The group menu's trigger is about to be replaced.
|
||||
this.closeTabGroupMenu();
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
|
||||
@@ -5724,9 +5738,11 @@ class CodemanApp {
|
||||
// The rebuild below destroys the focused row. In the grouped tree, put focus
|
||||
// back on the same item (by identity) so a background render or a keyboard
|
||||
// collapse does not drop a keyboard user to <body>.
|
||||
const focusWasInside = container.contains(document.activeElement);
|
||||
// An edit made from a menu (focus now on <body>) asks to land back in the rail.
|
||||
const focusWasInside = container.contains(document.activeElement) || this._tabRefocusAfterEdit === true;
|
||||
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
|
||||
this._tabFocusIdentity = null;
|
||||
this._tabRefocusAfterEdit = false;
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance.
|
||||
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
||||
@@ -5887,6 +5903,9 @@ class CodemanApp {
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
this.setupTabDragHandlers();
|
||||
// The grouped rail drags with its own pointer model (rows across groups,
|
||||
// group reorder); bound once, inert unless the rail is grouped.
|
||||
this._bindTabLayoutPointerDrag(container);
|
||||
|
||||
// Set up keyboard navigation for tabs
|
||||
this.setupTabKeyboardNavigation(container);
|
||||
@@ -6211,18 +6230,22 @@ class CodemanApp {
|
||||
if (groupId) toggle();
|
||||
else this._activateTabRow(current);
|
||||
break;
|
||||
case 'F2':
|
||||
if (!groupId || !this.startTabGroupRename(groupId)) return;
|
||||
break;
|
||||
case 'F10':
|
||||
case 'ContextMenu':
|
||||
case 'ContextMenu': {
|
||||
if (e.key === 'F10' && !e.shiftKey) return;
|
||||
if (current.dataset.id) {
|
||||
this.openTabRailActionMenu?.(
|
||||
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
|
||||
current.dataset.id
|
||||
);
|
||||
const synthetic = { preventDefault() {}, stopPropagation() {}, currentTarget: current };
|
||||
if (groupId) {
|
||||
this.openTabGroupMenu(synthetic, groupId);
|
||||
} else if (current.dataset.id) {
|
||||
this.openTabRailActionMenu?.(synthetic, current.dataset.id);
|
||||
} else if (current.dataset.webviewId) {
|
||||
this.showWebviewModal?.(current.dataset.webviewId);
|
||||
this.openTabWebviewMenu(synthetic, current.dataset.webviewId);
|
||||
} else return;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -6356,6 +6379,12 @@ class CodemanApp {
|
||||
// owner-scoped server-side, so this is a saved request, not a guard.)
|
||||
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
|
||||
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
|
||||
// Our own write is in flight: its response is the newer truth, and a read
|
||||
// racing it could repaint the pre-edit layout. Re-read once it settles.
|
||||
if (this._tabLayoutEditor?.hasPending()) {
|
||||
this._tabLayoutReloadPending = true;
|
||||
return;
|
||||
}
|
||||
this._loadTabLayout();
|
||||
}
|
||||
|
||||
@@ -6372,6 +6401,19 @@ class CodemanApp {
|
||||
// An overtaken response is already dropped by the coordinator; this guards a
|
||||
// reordering between the coordinator and an SSE-triggered reload.
|
||||
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
|
||||
const editor = this._tabLayoutEditor;
|
||||
if (editor) {
|
||||
if (next && editor.isWriting()) {
|
||||
// The write's own response decides; read again after it.
|
||||
this._tabLayoutReloadPending = true;
|
||||
return;
|
||||
}
|
||||
// Unsaved edits are rebased onto the read (adoptExternal repaints); with
|
||||
// none, the editor is simply rebuilt from the new layout on next use.
|
||||
if (next && editor.hasPending() && editor.adoptExternal(next)) return;
|
||||
editor.dispose();
|
||||
this._tabLayoutEditor = null;
|
||||
}
|
||||
this.tabLayout = next;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const collapsed = storage && next
|
||||
@@ -6384,6 +6426,11 @@ 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();
|
||||
// Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
|
||||
if (next && !this._tabLayoutRestoreChecked) {
|
||||
this._tabLayoutRestoreChecked = true;
|
||||
this._restorePendingTabLayoutEdits();
|
||||
}
|
||||
}
|
||||
|
||||
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
|
||||
@@ -6446,6 +6493,551 @@ class CodemanApp {
|
||||
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: editing groups from the vertical rail
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Every edit is a named operation (tab-layout-browser.js) applied to the rail
|
||||
// at once and saved by ONE serialized PUT /api/tab-layout at a time, with the
|
||||
// version the server last returned. A 409 is rebased onto the server's layout
|
||||
// and retried; a failure re-reads. Editing is a vertical-rail feature: the
|
||||
// header strip, the sidebar and phones never offer it.
|
||||
|
||||
_tabLayoutEditable() {
|
||||
return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical');
|
||||
}
|
||||
|
||||
/** child session id -> parent session id, so a moved session takes the sessions that follow it. */
|
||||
_tabLayoutParents() {
|
||||
const parents = {};
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session?.parentSessionId && session.parentSessionId !== session.id) parents[session.id] = session.parentSessionId;
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
async _putTabLayout({ baseVersion, layout }) {
|
||||
const body = { baseVersion, layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } };
|
||||
const response = await this._api('/api/tab-layout', { method: 'PUT', body });
|
||||
if (!response) return { ok: false, status: 0, layout: null };
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {}
|
||||
return { ok: response.ok, status: response.status, layout: data?.data?.layout || null };
|
||||
}
|
||||
|
||||
_ensureTabLayoutEditor() {
|
||||
if (this._tabLayoutEditor || !this.tabLayout) return this._tabLayoutEditor || null;
|
||||
this._tabLayoutEditor = window.CodemanTabLayout.createEditCoordinator({
|
||||
initialLayout: this.tabLayout,
|
||||
put: (request) => this._putTabLayout(request),
|
||||
fetchLayout: async () => {
|
||||
const data = await this._apiJson('/api/tab-layout');
|
||||
if (!data?.layout) throw new Error('Tab layout unavailable');
|
||||
return data.layout;
|
||||
},
|
||||
applyLayout: (layout) => this._adoptEditedTabLayout(layout),
|
||||
reportError: (message) => this.showToast?.(message, 'error'),
|
||||
onFailure: () => {
|
||||
// The rail may still show an edit the server refused: read the truth.
|
||||
this._tabLayoutReloadPending = true;
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!this._tabLayoutReloadPending) return;
|
||||
this._tabLayoutReloadPending = false;
|
||||
this._loadTabLayout();
|
||||
},
|
||||
});
|
||||
return this._tabLayoutEditor;
|
||||
}
|
||||
|
||||
/** The editor's view of the layout (optimistic or confirmed) becomes the rail. */
|
||||
_adoptEditedTabLayout(layout) {
|
||||
this.tabLayout = layout;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
if (storage) {
|
||||
// Forget collapse state for groups that no longer exist.
|
||||
const collapsed = window.CodemanTabLayout.loadCollapsedGroupIds(storage, layout.groups.map((group) => group.id));
|
||||
if (collapsed.ok) this.collapsedTabGroupIds = new Set(collapsed.ids);
|
||||
}
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one edit. `focusIdentity` names the tree item that should hold focus
|
||||
* afterwards (the moved row, the renamed group), so a keyboard user who acted
|
||||
* from a menu lands back in the rail rather than on <body>.
|
||||
*/
|
||||
editTabLayout(operation, focusIdentity) {
|
||||
if (!this._tabLayoutEditable()) return false;
|
||||
const active = document.activeElement;
|
||||
const rail = this.$('sessionTabs');
|
||||
if (focusIdentity && (!active || active === document.body || rail?.contains(active))) {
|
||||
this._tabFocusIdentity = focusIdentity;
|
||||
this._tabRefocusAfterEdit = true;
|
||||
}
|
||||
try {
|
||||
this._ensureTabLayoutEditor().enqueue(operation);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this._tabRefocusAfterEdit = false;
|
||||
this.showToast?.(error?.message || 'Could not save tab groups.', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_newTabGroupId() {
|
||||
return globalThis.crypto?.randomUUID?.() || `group-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** New group (optionally holding `ref`), then straight into renaming it. */
|
||||
createTabGroup({ ref = null, index } = {}) {
|
||||
if (!this._tabLayoutEditable()) return false;
|
||||
const id = this._newTabGroupId();
|
||||
const name = window.CodemanI18n?.t?.('New group') || 'New group';
|
||||
if (!this.editTabLayout({ type: 'createGroup', id, name, ...(Number.isInteger(index) ? { index } : {}) }, `group:${id}`)) {
|
||||
return false;
|
||||
}
|
||||
if (ref) this.editTabLayout({ type: 'moveRef', ref, groupId: id, index: 0, parents: this._tabLayoutParents() });
|
||||
this.startTabGroupRename(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteTabGroup(groupId) {
|
||||
const groups = this.tabLayout?.groups || [];
|
||||
const index = groups.findIndex((group) => group.id === groupId);
|
||||
if (index < 0) return false;
|
||||
if (!window.confirm(`Delete group "${groups[index].name}"? Its tabs move to Ungrouped.`)) return false;
|
||||
const neighbour = groups[index + 1] || groups[index - 1];
|
||||
return this.editTabLayout({ type: 'deleteGroup', groupId }, neighbour ? `group:${neighbour.id}` : null);
|
||||
}
|
||||
|
||||
moveTabGroup(groupId, delta) {
|
||||
const groups = this.tabLayout?.groups || [];
|
||||
const from = groups.findIndex((group) => group.id === groupId);
|
||||
const to = from + delta;
|
||||
if (from < 0 || to < 0 || to >= groups.length) return false;
|
||||
return this.editTabLayout({ type: 'reorderGroup', groupId, index: to }, `group:${groupId}`);
|
||||
}
|
||||
|
||||
/** Where a ref is stored: { groupId (null = Ungrouped), refs, index } or null. */
|
||||
_tabRefLocation(ref) {
|
||||
const same = (candidate) => candidate.kind === ref.kind && candidate.id === ref.id;
|
||||
for (const group of this.tabLayout?.groups || []) {
|
||||
const index = group.refs.findIndex(same);
|
||||
if (index >= 0) return { groupId: group.id, refs: group.refs, index };
|
||||
}
|
||||
const index = this.tabLayout?.ungrouped?.findIndex(same) ?? -1;
|
||||
return index >= 0 ? { groupId: null, refs: this.tabLayout.ungrouped, index } : null;
|
||||
}
|
||||
|
||||
moveTabRef(ref, groupId, anchor = null, placement = 'before') {
|
||||
const parents = this._tabLayoutParents();
|
||||
let destination;
|
||||
try {
|
||||
destination = window.CodemanTabLayout.moveDestination(this.tabLayout, ref, groupId, anchor, placement, parents);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return this.editTabLayout({ type: 'moveRef', ref, ...destination, parents }, `${ref.kind}:${ref.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group placement actions for a row's action menu: reorder within its
|
||||
* container, move to another group, out to Ungrouped, or into a new group.
|
||||
* Empty outside the vertical rail, so the header strip's menu is unchanged.
|
||||
*/
|
||||
_tabRefMoveActions(ref) {
|
||||
if (!this._tabLayoutEditable()) return [];
|
||||
const location = this._tabRefLocation(ref);
|
||||
if (!location) return [];
|
||||
const actions = [];
|
||||
const grouped = this.tabLayout.groups.length > 0;
|
||||
// Up/down follow the STORED order, which is what the rail paints unless a
|
||||
// sort is on (then the sort decides and there is nothing to reorder).
|
||||
if (grouped && !this.isTabRailSorted()) {
|
||||
// The sessions that follow this one move with it, so "down" means past
|
||||
// the first row that is not part of that block.
|
||||
const moving = new Set(window.CodemanTabLayout.movingRefKeys(this.tabLayout, ref, this._tabLayoutParents()));
|
||||
const previous = location.refs[location.index - 1];
|
||||
const next = location.refs.slice(location.index + 1).find((candidate) => !moving.has(`${candidate.kind}:${candidate.id}`));
|
||||
if (previous) actions.push({ label: 'Move up', run: () => this.moveTabRef(ref, location.groupId, previous, 'before') });
|
||||
if (next) actions.push({ label: 'Move down', run: () => this.moveTabRef(ref, location.groupId, next, 'after') });
|
||||
}
|
||||
for (const group of this.tabLayout.groups) {
|
||||
if (group.id === location.groupId) continue;
|
||||
actions.push({ label: `Move to ${group.name}`, run: () => this.moveTabRef(ref, group.id) });
|
||||
}
|
||||
if (location.groupId !== null) actions.push({ label: 'Move to Ungrouped', run: () => this.moveTabRef(ref, null) });
|
||||
actions.push({ label: 'Move to new group', run: () => this.createTabGroup({ ref }) });
|
||||
return actions;
|
||||
}
|
||||
|
||||
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
|
||||
|
||||
/**
|
||||
* Close the open group / web-tab menu. Every dismissal path lands here:
|
||||
* Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an
|
||||
* action, and any full re-render of the rail (which would orphan its trigger).
|
||||
*/
|
||||
closeTabGroupMenu({ restoreFocus = false } = {}) {
|
||||
const menu = this._tabGroupMenu;
|
||||
if (!menu) return;
|
||||
const trigger = this._tabGroupMenuTrigger;
|
||||
const identity = this._tabGroupMenuKey;
|
||||
this._tabGroupMenu = null;
|
||||
this._tabGroupMenuTrigger = null;
|
||||
this._tabGroupMenuKey = null;
|
||||
document.removeEventListener('pointerdown', this._tabGroupMenuOutside, true);
|
||||
document.removeEventListener('keydown', this._tabGroupMenuKeydown, true);
|
||||
window.removeEventListener('resize', this._tabGroupMenuResize);
|
||||
this._tabGroupMenuOutside = this._tabGroupMenuKeydown = this._tabGroupMenuResize = null;
|
||||
menu.remove();
|
||||
if (!restoreFocus) return;
|
||||
// The trigger may have been re-rendered while the menu was open; find the
|
||||
// live tree item by identity.
|
||||
const rail = this.$('sessionTabs');
|
||||
const item =
|
||||
(trigger?.isConnected && trigger.closest('[role="treeitem"]')) ||
|
||||
[...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
|
||||
item?.focus();
|
||||
}
|
||||
|
||||
openTabGroupMenu(event, groupId) {
|
||||
const groups = this.tabLayout?.groups || [];
|
||||
const index = groups.findIndex((group) => group.id === groupId);
|
||||
if (index < 0) return false;
|
||||
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
|
||||
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
|
||||
{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) },
|
||||
...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []),
|
||||
...(index < groups.length - 1 ? [{ label: 'Move group down', run: () => this.moveTabGroup(groupId, 1) }] : []),
|
||||
{ label: 'Delete group', className: 'danger', run: () => this.deleteTabGroup(groupId) },
|
||||
]);
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
if (!moves.length) {
|
||||
this.showWebviewModal?.(webviewId);
|
||||
return false;
|
||||
}
|
||||
return this._openTabLayoutMenu(event, `webview:${webviewId}`, 'Web tab actions', [
|
||||
{ label: 'Web tab settings', run: () => this.showWebviewModal?.(webviewId) },
|
||||
...moves,
|
||||
]);
|
||||
}
|
||||
|
||||
_openTabLayoutMenu(event, identity, ariaLabel, actions) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
const trigger = event?.currentTarget || null;
|
||||
// Opening the same menu again closes it (a toggle, like the row menu).
|
||||
if (this._tabGroupMenu && this._tabGroupMenuKey === identity) {
|
||||
this.closeTabGroupMenu();
|
||||
return false;
|
||||
}
|
||||
this.closeTabGroupMenu();
|
||||
this.closeTabRailActionMenu?.();
|
||||
if (!this._tabLayoutEditable()) return false;
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'tab-rail-action-menu tab-layout-group-action-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', ariaLabel);
|
||||
for (const action of actions) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.textContent = action.label;
|
||||
if (action.className) button.className = action.className;
|
||||
button.addEventListener('click', () => {
|
||||
this.closeTabGroupMenu();
|
||||
action.run();
|
||||
});
|
||||
menu.appendChild(button);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const anchor = (trigger?.getBoundingClientRect ? trigger : null) || this.$('sessionTabs');
|
||||
const rect = anchor?.getBoundingClientRect?.() || { left: 8, bottom: 8, right: 8 };
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const left = event?.clientX && event.type === 'contextmenu' ? event.clientX : rect.left;
|
||||
menu.style.left = `${Math.max(8, Math.min(left, window.innerWidth - menuRect.width - 8))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
|
||||
|
||||
this._tabGroupMenu = menu;
|
||||
this._tabGroupMenuTrigger = trigger;
|
||||
this._tabGroupMenuKey = identity;
|
||||
this._tabGroupMenuOutside = (pointerEvent) => {
|
||||
if (menu.contains(pointerEvent.target) || (trigger && trigger.contains?.(pointerEvent.target))) return;
|
||||
this.closeTabGroupMenu();
|
||||
};
|
||||
// Capture on document, so Escape closes THIS menu and nothing else (the
|
||||
// global Escape handler defers to it, see the keydown listener in init).
|
||||
this._tabGroupMenuKeydown = (keyEvent) => {
|
||||
if (keyEvent.key !== 'Escape') return;
|
||||
keyEvent.preventDefault();
|
||||
keyEvent.stopImmediatePropagation();
|
||||
this.closeTabGroupMenu({ restoreFocus: true });
|
||||
};
|
||||
this._tabGroupMenuResize = () => this.closeTabGroupMenu();
|
||||
document.addEventListener('pointerdown', this._tabGroupMenuOutside, true);
|
||||
document.addEventListener('keydown', this._tabGroupMenuKeydown, true);
|
||||
window.addEventListener('resize', this._tabGroupMenuResize);
|
||||
menu.addEventListener('keydown', (keyEvent) => {
|
||||
const buttons = [...menu.querySelectorAll('button')];
|
||||
const at = buttons.indexOf(document.activeElement);
|
||||
if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
|
||||
keyEvent.preventDefault();
|
||||
buttons[(at + (keyEvent.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
||||
} else if (keyEvent.key === 'Home' || keyEvent.key === 'End') {
|
||||
keyEvent.preventDefault();
|
||||
buttons[keyEvent.key === 'Home' ? 0 : buttons.length - 1]?.focus();
|
||||
} else if (keyEvent.key === 'Tab') {
|
||||
// Tab would walk out and leave the popup on screen: dismiss to the row.
|
||||
keyEvent.preventDefault();
|
||||
this.closeTabGroupMenu({ restoreFocus: true });
|
||||
}
|
||||
});
|
||||
// Focus leaving by any other route (a click elsewhere, a programmatic move).
|
||||
// Hops between the menu's own items are not a departure.
|
||||
menu.addEventListener('focusout', (focusEvent) => {
|
||||
if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return;
|
||||
if (this._tabGroupMenu === menu) this.closeTabGroupMenu();
|
||||
});
|
||||
menu.querySelector('button')?.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Inline group rename ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Rename a group in place. Shares the session rename's ownership handle
|
||||
* (`_activeRename`), so starting one cancels the other and only the CURRENT
|
||||
* editor may release the render guard. Enter or blur commits, Escape cancels,
|
||||
* IME composition keys belong to the IME. The commit goes through the edit
|
||||
* coordinator, so it is serialized behind any write already in flight.
|
||||
*/
|
||||
startTabGroupRename(groupId) {
|
||||
if (!this.tabLayout?.groups?.some((candidate) => candidate.id === groupId)) return false;
|
||||
// Cancelling another editor re-renders the rail, so look the header up after.
|
||||
this._activeRename?.cancel();
|
||||
const group = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId);
|
||||
const header = this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||
const label = header?.querySelector('.tab-layout-group-name');
|
||||
if (!group || !label) return false;
|
||||
this._inlineRenameActive = true;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'tab-layout-group-rename-input';
|
||||
input.value = group.name;
|
||||
input.maxLength = 60;
|
||||
input.setAttribute('aria-label', 'Group name');
|
||||
label.classList.add('tab-layout-group-name--renaming');
|
||||
label.replaceChildren(input);
|
||||
// The header toggles collapse on click and opens its menu on right-click;
|
||||
// neither may fire from inside the editor.
|
||||
for (const type of ['click', 'contextmenu', 'pointerdown']) input.addEventListener(type, (e) => e.stopPropagation());
|
||||
|
||||
let settled = false;
|
||||
const handle = { groupId, cancel: () => settle(false) };
|
||||
const settle = (commit) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const name = input.value.trim();
|
||||
// Only the current editor owns the guard: a newer rename keeps it.
|
||||
if (this._activeRename !== handle) return;
|
||||
this._activeRename = null;
|
||||
this._inlineRenameActive = false;
|
||||
const current = this.tabLayout?.groups?.find((candidate) => candidate.id === groupId);
|
||||
if (commit && current && name && name !== current.name) {
|
||||
if (this.editTabLayout({ type: 'renameGroup', groupId, name }, `group:${groupId}`)) return;
|
||||
}
|
||||
this._tabFocusIdentity = `group:${groupId}`;
|
||||
this._tabRefocusAfterEdit = true;
|
||||
this._fullRenderSessionTabs();
|
||||
};
|
||||
this._activeRename = handle;
|
||||
input.addEventListener('keydown', (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
settle(true);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
settle(false);
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => settle(true));
|
||||
input.focus();
|
||||
input.select();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Pointer drag in the grouped rail ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Drag rows between groups and reorder groups, in the GROUPED rail only.
|
||||
* Pointer Events (mouse and pen; touch keeps scrolling the rail), bound once
|
||||
* on the container, which survives every re-render. The flat rail and the
|
||||
* header strip keep the HTML5 drag in setupTabDragHandlers() untouched.
|
||||
* Keyboard equivalents live in the row and group menus.
|
||||
*/
|
||||
_bindTabLayoutPointerDrag(container) {
|
||||
if (!container || container._tabLayoutDragBound) return;
|
||||
container._tabLayoutDragBound = true;
|
||||
container.addEventListener('pointerdown', (e) => this._onTabLayoutPointerDown(e, container));
|
||||
container.addEventListener('pointermove', (e) => this._onTabLayoutPointerMove(e, container));
|
||||
container.addEventListener('pointerup', (e) => this._finishTabLayoutPointerDrag(e, container));
|
||||
container.addEventListener('pointercancel', () => this._cancelTabLayoutPointerDrag(container));
|
||||
container.addEventListener('lostpointercapture', () => this._cancelTabLayoutPointerDrag(container));
|
||||
}
|
||||
|
||||
_onTabLayoutPointerDown(e, container) {
|
||||
if (e.button !== 0 || e.pointerType === 'touch' || !container.classList.contains('session-tabs--grouped')) return;
|
||||
if (this._inlineRenameActive || !this._tabLayoutEditable()) return;
|
||||
// Controls keep their own click; only the row body or the header drags.
|
||||
if (e.target.closest('.tab-actions, .tab-badge, .tab-layout-group-menu, button, input, [onclick*="stopPropagation"]')) return;
|
||||
const header = e.target.closest('[data-tab-group-header]');
|
||||
const row = header ? null : e.target.closest('.session-tab');
|
||||
let source = null;
|
||||
if (header) source = { type: 'group', groupId: header.dataset.tabGroupHeader };
|
||||
else if (row?.dataset.webviewId) source = { type: 'ref', ref: { kind: 'webview', id: row.dataset.webviewId } };
|
||||
else if (row?.dataset.id) source = { type: 'ref', ref: { kind: 'session', id: row.dataset.id } };
|
||||
if (!source) return;
|
||||
this._tabLayoutDrag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, source, origin: header || row, active: false, target: null };
|
||||
}
|
||||
|
||||
/** What a pointer at (x, y) would drop onto, from the rendered rail. */
|
||||
_tabLayoutDropTarget(x, y, container) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit || !container.contains(hit)) return null;
|
||||
const row = hit.closest('.session-tab');
|
||||
const section = hit.closest('.tab-layout-group');
|
||||
const sectionGroup = section ? section.dataset.tabGroupId || null : undefined;
|
||||
// A sorted rail paints its own order, so a row can only be dropped INTO a
|
||||
// group, never between two rows.
|
||||
if (row && section && !this.isTabRailSorted()) {
|
||||
const ref = row.dataset.webviewId ? { kind: 'webview', id: row.dataset.webviewId } : { kind: 'session', id: row.dataset.id };
|
||||
const rect = row.getBoundingClientRect();
|
||||
return { type: 'ref', ref, groupId: sectionGroup, placement: y >= rect.top + rect.height / 2 ? 'after' : 'before', element: row };
|
||||
}
|
||||
if (sectionGroup === undefined) return null;
|
||||
const element = section.querySelector(':scope > .tab-layout-group-header');
|
||||
return sectionGroup === null ? { type: 'ungrouped', element } : { type: 'group', groupId: sectionGroup, element };
|
||||
}
|
||||
|
||||
_clearTabLayoutDropMarks(container) {
|
||||
container.querySelectorAll('.tab-layout-drop-before, .tab-layout-drop-after, .tab-layout-drop-into').forEach((el) =>
|
||||
el.classList.remove('tab-layout-drop-before', 'tab-layout-drop-after', 'tab-layout-drop-into')
|
||||
);
|
||||
}
|
||||
|
||||
_onTabLayoutPointerMove(e, container) {
|
||||
const drag = this._tabLayoutDrag;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
if (!drag.active) {
|
||||
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 6) return;
|
||||
drag.active = true;
|
||||
drag.origin.classList.add('tab-layout-dragging');
|
||||
container.classList.add('tab-layout-drag-active');
|
||||
this.closeTabGroupMenu();
|
||||
this.closeTabRailActionMenu?.();
|
||||
try {
|
||||
container.setPointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
this._tabLayoutDragKeydown = (keyEvent) => {
|
||||
if (keyEvent.key !== 'Escape') return;
|
||||
keyEvent.preventDefault();
|
||||
keyEvent.stopImmediatePropagation();
|
||||
this._cancelTabLayoutPointerDrag(container);
|
||||
};
|
||||
document.addEventListener('keydown', this._tabLayoutDragKeydown, true);
|
||||
}
|
||||
e.preventDefault();
|
||||
const target = this._tabLayoutDropTarget(e.clientX, e.clientY, container);
|
||||
this._clearTabLayoutDropMarks(container);
|
||||
drag.target = target;
|
||||
if (!target?.element) return;
|
||||
const cls = target.type === 'ref' && drag.source.type === 'ref' ? `tab-layout-drop-${target.placement}` : 'tab-layout-drop-into';
|
||||
target.element.classList.add(cls);
|
||||
}
|
||||
|
||||
_cancelTabLayoutPointerDrag(container) {
|
||||
const drag = this._tabLayoutDrag;
|
||||
if (!drag) return;
|
||||
this._tabLayoutDrag = null;
|
||||
drag.origin?.classList.remove('tab-layout-dragging');
|
||||
container?.classList.remove('tab-layout-drag-active');
|
||||
if (container) this._clearTabLayoutDropMarks(container);
|
||||
if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true);
|
||||
this._tabLayoutDragKeydown = null;
|
||||
if (drag.active) {
|
||||
// The click that ends a drag must not also select the row or toggle the header.
|
||||
const swallow = (clickEvent) => {
|
||||
clickEvent.stopPropagation();
|
||||
clickEvent.preventDefault();
|
||||
};
|
||||
window.addEventListener('click', swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
_finishTabLayoutPointerDrag(e, container) {
|
||||
const drag = this._tabLayoutDrag;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
const target = drag.active ? this._tabLayoutDropTarget(e.clientX, e.clientY, container) || drag.target : null;
|
||||
this._cancelTabLayoutPointerDrag(container);
|
||||
if (!target) return;
|
||||
const operation = window.CodemanTabLayout.dropOperation(this.tabLayout, drag.source, target, this._tabLayoutParents());
|
||||
if (!operation) return;
|
||||
const identity = drag.source.type === 'group' ? `group:${drag.source.groupId}` : `${drag.source.ref.kind}:${drag.source.ref.id}`;
|
||||
this.editTabLayout(operation, identity);
|
||||
}
|
||||
|
||||
// ─── Unsaved edits across a reload ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* The page is going away with edits not yet confirmed: send them with a
|
||||
* keepalive PUT (it outlives the page) AND keep a copy in sessionStorage. If
|
||||
* the keepalive lands, the copy replays to no change after reload; if it lost
|
||||
* a race, the copy is rebased onto the fresh layout and saved properly.
|
||||
*/
|
||||
_persistPendingTabLayoutEdits() {
|
||||
const editor = this._tabLayoutEditor;
|
||||
const operations = editor?.pendingOperations?.() || [];
|
||||
if (!operations.length) return false;
|
||||
try {
|
||||
sessionStorage.setItem('codeman:tab-layout-pending', JSON.stringify({ operations }));
|
||||
} catch {}
|
||||
try {
|
||||
const layout = editor.getLayout();
|
||||
void fetch('/api/tab-layout', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseVersion: editor.baseVersion(), layout: { ...layout, updatedAt: layout.updatedAt || new Date().toISOString() } }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
|
||||
_restorePendingTabLayoutEdits() {
|
||||
let operations;
|
||||
try {
|
||||
const raw = sessionStorage.getItem('codeman:tab-layout-pending');
|
||||
if (!raw) return false;
|
||||
sessionStorage.removeItem('codeman:tab-layout-pending');
|
||||
operations = JSON.parse(raw)?.operations;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(operations) || !operations.length || !this.tabLayout || !window.CodemanTabLayout) return false;
|
||||
return this._ensureTabLayoutEditor().restore(operations);
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -6557,7 +7149,9 @@ class CodemanApp {
|
||||
* each group on its own (`putLegacyOrder`), so nothing moves there, no
|
||||
* session:orderChanged comes back, and this client would keep a swapped
|
||||
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
|
||||
* drag is off in the grouped rail. Any other layout: always allowed.
|
||||
* the HTML5 flat-order drag is off in the grouped rail (its own pointer drag
|
||||
* and the row menu's moves go through moveRef instead, which can cross a
|
||||
* group). Any other layout: always allowed.
|
||||
*/
|
||||
_canSwapActiveTabWith(neighbourId) {
|
||||
const projection = this._projectTabGroups();
|
||||
|
||||
@@ -68,6 +68,23 @@
|
||||
'Session Manager': '会话管理器',
|
||||
'Session actions': '会话操作',
|
||||
Ungrouped: '未分组',
|
||||
'Group actions': '分组操作',
|
||||
'Group name': '分组名称',
|
||||
'Web tab actions': '网页标签操作',
|
||||
'Web tab settings': '网页标签设置',
|
||||
'New group': '新建分组',
|
||||
'Rename group': '重命名分组',
|
||||
'Move group up': '上移分组',
|
||||
'Move group down': '下移分组',
|
||||
'Delete group': '删除分组',
|
||||
'Move up': '上移',
|
||||
'Move down': '下移',
|
||||
'Move to Ungrouped': '移到未分组',
|
||||
'Move to new group': '移到新分组',
|
||||
'Could not save tab groups.': '无法保存标签分组。',
|
||||
'Tab groups changed elsewhere; part of your edit no longer applies.':
|
||||
'标签分组已在别处更改;你的部分编辑已不再适用。',
|
||||
'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。',
|
||||
'Open session manager': '打开会话管理器',
|
||||
Attachments: '附件',
|
||||
'Open attachment history': '打开附件历史',
|
||||
@@ -934,6 +951,12 @@
|
||||
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
|
||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to (.+)$/, (_m, group) => `移到 ${group}`],
|
||||
[
|
||||
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
|
||||
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
|
||||
],
|
||||
];
|
||||
for (const [pattern, replacement] of patterns) {
|
||||
const match = source.match(pattern);
|
||||
|
||||
@@ -736,6 +736,78 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-v
|
||||
content: '\25B8';
|
||||
}
|
||||
|
||||
/* Group editing: the header's menu glyph, the inline rename editor, and the
|
||||
pointer-drag marks. All of it lives inside the grouped rail only. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:hover .tab-layout-group-menu,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header:focus-visible .tab-layout-group-menu {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-menu:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-tertiary, var(--bg-hover));
|
||||
}
|
||||
|
||||
/* The label is a nowrap ellipsis box; while it holds the editor it becomes a
|
||||
plain flex slot so the input gets the whole width and repaints as you type. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-name--renaming {
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-rename-input {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .session-tab,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layout-group-header {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-before {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-after {
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drop-into {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .header-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Read-only browser projection of the owner tab layout.
|
||||
* @fileoverview Browser projection and editing of the owner tab layout.
|
||||
*
|
||||
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
|
||||
* is the server model). Browser assets cannot import that TypeScript, so this
|
||||
* module is a small, dependency-free mirror that owns three things:
|
||||
* module is a small, dependency-free mirror that owns four things:
|
||||
*
|
||||
* 1. Projection: which live sessions and open web tabs land in which group,
|
||||
* and which rows a collapsed group hides.
|
||||
@@ -12,6 +12,10 @@
|
||||
* byte-identical to the flat rail's row.
|
||||
* 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
|
||||
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
|
||||
* version conflict.
|
||||
*
|
||||
* The server stays the only authority for layout content. Collapse is a
|
||||
* per-device view preference and lives in localStorage only.
|
||||
@@ -34,8 +38,16 @@
|
||||
!!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))];
|
||||
const copyRefs = (value) =>
|
||||
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
|
||||
// `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 };
|
||||
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. */
|
||||
const MAX_GROUPS = 32;
|
||||
const MAX_NAME_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
|
||||
@@ -46,6 +58,7 @@
|
||||
const groups = Array.isArray(value.groups) ? value.groups : [];
|
||||
return {
|
||||
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
|
||||
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
|
||||
groups: groups
|
||||
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
|
||||
.map((group) => ({
|
||||
@@ -276,10 +289,17 @@
|
||||
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
|
||||
return (
|
||||
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
|
||||
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
|
||||
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)" oncontextmenu="event.preventDefault(); app.openTabGroupMenu(event, this.dataset.tabGroupHeader)">` +
|
||||
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
|
||||
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
||||
`<span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<span class="tab-layout-group-count">${section.count}</span>` +
|
||||
// Pointer path to the group menu (right-click on the header works too).
|
||||
// Deliberately NOT a button and not focusable: a treeitem holds no
|
||||
// interactive children, and the keyboard path is Shift+F10 /
|
||||
// ContextMenu on the header itself. aria-hidden keeps the glyph out of
|
||||
// the header's accessible name.
|
||||
'<span class="tab-layout-group-menu" aria-hidden="true" title="Group actions" ' +
|
||||
'onclick="event.stopPropagation(); app.openTabGroupMenu(event, this.closest(\'[data-tab-group-header]\').dataset.tabGroupHeader)">⋯</span></div>' +
|
||||
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
|
||||
);
|
||||
})
|
||||
@@ -340,6 +360,364 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Editing ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The browser edits through NAMED operations, not by diffing arrays: a write
|
||||
// that loses a version race (409) is rebased by replaying the same operations
|
||||
// on the layout the server returned, so a concurrent edit elsewhere survives.
|
||||
// The server stays the authority: it re-validates and normalizes every PUT.
|
||||
|
||||
function editError(message) {
|
||||
throw new Error(`Tab layout edit failed: ${message}`);
|
||||
}
|
||||
|
||||
const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length);
|
||||
|
||||
function groupName(value) {
|
||||
const name = typeof value === 'string' ? value.trim() : '';
|
||||
if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`);
|
||||
return name;
|
||||
}
|
||||
|
||||
function refLocations(layout) {
|
||||
return [
|
||||
...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))),
|
||||
...layout.ungrouped.map((ref) => ({ groupId: null, ref })),
|
||||
];
|
||||
}
|
||||
|
||||
function containerRefs(layout, groupId) {
|
||||
if (groupId === null) return layout.ungrouped;
|
||||
const group = layout.groups.find((candidate) => candidate.id === groupId);
|
||||
if (!group) editError('unknown group');
|
||||
return group.refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that move together with `ref`: the session plus every descendant
|
||||
* that still follows its parent (non-manual, parent stored). Mirrors the
|
||||
* server's moveRef block so the optimistic rail matches what it will store.
|
||||
* `parents` maps a session id to its parent session id.
|
||||
*/
|
||||
function lineageBlock(layout, ref, parents) {
|
||||
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
|
||||
const children = new Map();
|
||||
for (const [childId, parentId] of Object.entries(parents || {})) {
|
||||
const child = stored.get(`session:${childId}`);
|
||||
if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue;
|
||||
if (!children.has(parentId)) children.set(parentId, []);
|
||||
children.get(parentId).push(childId);
|
||||
}
|
||||
const keys = new Set();
|
||||
const visit = (key) => {
|
||||
if (keys.has(key)) return;
|
||||
keys.add(key);
|
||||
if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`);
|
||||
};
|
||||
visit(refKey(ref));
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a moved row lands, as the server's `index` (counted AFTER the moved
|
||||
* block is taken out): before or after `anchor` in that container, or at its
|
||||
* end when there is no anchor.
|
||||
*/
|
||||
function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
const block = lineageBlock(layout, ref, parents);
|
||||
const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate)));
|
||||
if (!anchor) return { groupId, index: remaining.length };
|
||||
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
|
||||
if (at < 0) return { groupId, index: remaining.length };
|
||||
return { groupId, index: placement === 'after' ? at + 1 : at };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a finished drag to ONE operation (or null for a drop that changes
|
||||
* nothing). Pure, so the drop -> PUT mapping is testable without a pointer.
|
||||
*
|
||||
* source: { type: 'ref', ref } | { type: 'group', groupId }
|
||||
* target: { type: 'ref', ref, groupId, placement: 'before' | 'after' }
|
||||
* | { type: 'group', groupId } (a named group's header or empty body)
|
||||
* | { type: 'ungrouped' }
|
||||
*
|
||||
* A group dropped on another group (or any row in it) takes that group's slot;
|
||||
* dropped on the Ungrouped section it goes last. A row dropped on a row lands
|
||||
* before/after it, on a header it is appended to that group.
|
||||
*/
|
||||
function dropOperation(layoutInput, source, target, parents) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
if (!source || !target) return null;
|
||||
if (source.type === 'group') {
|
||||
const from = layout.groups.findIndex((group) => group.id === source.groupId);
|
||||
if (from < 0) return null;
|
||||
const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null);
|
||||
const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId);
|
||||
if (to < 0 || to === from) return null;
|
||||
return { type: 'reorderGroup', groupId: source.groupId, index: to };
|
||||
}
|
||||
if (source.type !== 'ref' || !validRef(source.ref)) return null;
|
||||
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref));
|
||||
if (!location) return null;
|
||||
let groupId;
|
||||
let anchor = null;
|
||||
let placement = 'before';
|
||||
if (target.type === 'ref' && validRef(target.ref)) {
|
||||
// Onto itself or onto a row that moves with it: nowhere to go.
|
||||
if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null;
|
||||
groupId = target.groupId ?? null;
|
||||
anchor = target.ref;
|
||||
placement = target.placement === 'after' ? 'after' : 'before';
|
||||
} else if (target.type === 'group') {
|
||||
groupId = target.groupId ?? null;
|
||||
if (groupId === location.groupId) return null;
|
||||
} else if (target.type === 'ungrouped') {
|
||||
groupId = null;
|
||||
if (location.groupId === null) return null;
|
||||
} else return null;
|
||||
if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null;
|
||||
const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents);
|
||||
const operation = {
|
||||
type: 'moveRef',
|
||||
ref: { kind: source.ref.kind, id: source.ref.id },
|
||||
groupId: destination.groupId,
|
||||
index: destination.index,
|
||||
parents: parents || {},
|
||||
};
|
||||
return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one operation to a copy of the layout. Throws when the operation no
|
||||
* longer makes sense (an unknown group or row); a rebase drops that one
|
||||
* operation and keeps the rest. Replays are idempotent where it matters for
|
||||
* recovery: creating a group that already exists is a no-op.
|
||||
*/
|
||||
function applyOperation(layoutInput, operation) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
const op = operation || {};
|
||||
const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId);
|
||||
switch (op.type) {
|
||||
case 'createGroup': {
|
||||
if (typeof op.id !== 'string' || !op.id) editError('invalid group id');
|
||||
const name = groupName(op.name);
|
||||
if (layout.groups.some((group) => group.id === op.id)) return layout;
|
||||
if (layout.groups.length >= MAX_GROUPS) editError('group limit reached');
|
||||
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] });
|
||||
return layout;
|
||||
}
|
||||
case 'renameGroup':
|
||||
if (groupIndex < 0) editError('unknown group');
|
||||
layout.groups[groupIndex].name = groupName(op.name);
|
||||
return layout;
|
||||
case 'deleteGroup': {
|
||||
// Already gone (deleted elsewhere): nothing left to do.
|
||||
if (groupIndex < 0) return layout;
|
||||
const [removed] = layout.groups.splice(groupIndex, 1);
|
||||
layout.ungrouped.push(...removed.refs);
|
||||
return layout;
|
||||
}
|
||||
case 'reorderGroup': {
|
||||
if (groupIndex < 0) editError('unknown group');
|
||||
const [moved] = layout.groups.splice(groupIndex, 1);
|
||||
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
|
||||
return layout;
|
||||
}
|
||||
case 'moveRef': {
|
||||
if (!validRef(op.ref)) editError('invalid row');
|
||||
const targetKey = refKey(op.ref);
|
||||
if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row');
|
||||
const destinationId = op.groupId ?? null;
|
||||
containerRefs(layout, destinationId);
|
||||
const keys = lineageBlock(layout, op.ref, op.parents);
|
||||
const block = refLocations(layout)
|
||||
.filter((item) => keys.has(refKey(item.ref)))
|
||||
.map((item) => copyRef(item.ref));
|
||||
// A hand-moved child stops following its parent (server moveRef does the same).
|
||||
const head = block.find((item) => refKey(item) === targetKey);
|
||||
if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual';
|
||||
block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0));
|
||||
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref)));
|
||||
layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref)));
|
||||
const destination = containerRefs(layout, destinationId);
|
||||
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
|
||||
return layout;
|
||||
}
|
||||
default:
|
||||
return editError(`unknown operation ${op.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Layout content without version metadata: equal keys mean "nothing to save". */
|
||||
function contentKey(layoutInput) {
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
return JSON.stringify([layout.groups, layout.ungrouped]);
|
||||
}
|
||||
|
||||
/** Replay operations, dropping (and counting) the ones that no longer apply. */
|
||||
function replayOperations(base, operations) {
|
||||
let layout = normalizeLayout(base);
|
||||
const kept = [];
|
||||
let dropped = 0;
|
||||
for (const operation of operations) {
|
||||
try {
|
||||
layout = applyOperation(layout, operation);
|
||||
kept.push(operation);
|
||||
} catch (_error) {
|
||||
dropped++;
|
||||
}
|
||||
}
|
||||
return { layout, kept, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized, optimistic writer for `PUT /api/tab-layout`.
|
||||
*
|
||||
* - enqueue() applies an operation at once (the rail repaints optimistically)
|
||||
* and schedules a flush; operations enqueued in the same turn share a PUT.
|
||||
* - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are
|
||||
* sent on top of the version that write returns.
|
||||
* - A 409 carries the server's current layout: the in-flight operations are
|
||||
* replayed onto it and re-sent with its version (bounded attempts). A 400
|
||||
* (a row vanished between read and write) re-reads and rebases the same way.
|
||||
* - Anything else, or attempts exhausted, drops the batch and reports it; the
|
||||
* caller re-reads so the rail shows the server's truth.
|
||||
*
|
||||
* options: { initialLayout, put({ baseVersion, layout }) -> { ok, status,
|
||||
* layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message),
|
||||
* onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? }
|
||||
*/
|
||||
function createEditCoordinator(options) {
|
||||
let authoritative = normalizeLayout(options.initialLayout);
|
||||
let optimistic = authoritative;
|
||||
let pending = [];
|
||||
let inFlight = [];
|
||||
let writing = false;
|
||||
let timer = null;
|
||||
let disposed = false;
|
||||
const schedule = options.schedule || ((fn) => setTimeout(fn, 0));
|
||||
const cancel = options.cancel || ((handle) => clearTimeout(handle));
|
||||
const maxAttempts = options.maxAttempts || 3;
|
||||
const report = (message) => options.reportError?.(message);
|
||||
const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta);
|
||||
const queue = () => {
|
||||
if (timer === null) timer = schedule(flush);
|
||||
};
|
||||
|
||||
async function flush() {
|
||||
timer = null;
|
||||
if (disposed || writing || pending.length === 0) return;
|
||||
writing = true;
|
||||
inFlight = pending;
|
||||
pending = [];
|
||||
let failed = false;
|
||||
let reportedDrop = false;
|
||||
try {
|
||||
for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) {
|
||||
const desired = replayOperations(authoritative, inFlight);
|
||||
inFlight = desired.kept;
|
||||
if (desired.dropped && !reportedDrop) {
|
||||
reportedDrop = true;
|
||||
report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||
}
|
||||
// Nothing left to change (dropped, or already true on the server).
|
||||
if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) {
|
||||
inFlight = [];
|
||||
break;
|
||||
}
|
||||
const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout });
|
||||
if (disposed) return;
|
||||
if (response?.ok && response.layout) {
|
||||
authoritative = normalizeLayout(response.layout);
|
||||
inFlight = [];
|
||||
} else if (response?.status === 409 && response.layout) {
|
||||
authoritative = normalizeLayout(response.layout);
|
||||
} else if (response?.status === 400 && options.fetchLayout) {
|
||||
authoritative = normalizeLayout(await options.fetchLayout());
|
||||
if (disposed) return;
|
||||
} else {
|
||||
throw new Error('Tab layout save failed');
|
||||
}
|
||||
}
|
||||
if (inFlight.length) {
|
||||
failed = true;
|
||||
report('Tab groups kept changing elsewhere; your edit was not saved.');
|
||||
}
|
||||
} catch (_error) {
|
||||
failed = true;
|
||||
report('Could not save tab groups.');
|
||||
} finally {
|
||||
inFlight = [];
|
||||
writing = false;
|
||||
if (!disposed) {
|
||||
const rebased = replayOperations(authoritative, pending);
|
||||
pending = rebased.kept;
|
||||
optimistic = rebased.layout;
|
||||
publish({ authoritative: true });
|
||||
if (failed) options.onFailure?.();
|
||||
if (pending.length) queue();
|
||||
else options.onSettled?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */
|
||||
enqueue(operation) {
|
||||
optimistic = applyOperation(optimistic, operation);
|
||||
pending.push(operation);
|
||||
publish({ optimistic: true });
|
||||
queue();
|
||||
return normalizeLayout(optimistic);
|
||||
},
|
||||
/**
|
||||
* Re-apply operations recovered after a reload. Returns false (and queues
|
||||
* nothing) when the layout already reflects them, e.g. the keepalive save
|
||||
* landed before the page went away.
|
||||
*/
|
||||
restore(operations) {
|
||||
const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []);
|
||||
if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false;
|
||||
optimistic = replayed.layout;
|
||||
pending.push(...replayed.kept);
|
||||
publish({ optimistic: true });
|
||||
queue();
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Adopt a layout read from the server (SSE reload). Pending operations are
|
||||
* rebased onto it. Refused while a write is in flight (its result decides)
|
||||
* and for a layout older than the one already held.
|
||||
*/
|
||||
adoptExternal(layout) {
|
||||
if (disposed || writing) return false;
|
||||
const next = normalizeLayout(layout);
|
||||
if (next.version < authoritative.version) return false;
|
||||
authoritative = next;
|
||||
const rebased = replayOperations(next, pending);
|
||||
if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.');
|
||||
pending = rebased.kept;
|
||||
optimistic = rebased.layout;
|
||||
publish({ authoritative: true, external: true });
|
||||
return true;
|
||||
},
|
||||
flush,
|
||||
isWriting: () => writing,
|
||||
hasPending: () => writing || pending.length > 0,
|
||||
/** Every operation not yet confirmed by the server, oldest first. */
|
||||
pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])),
|
||||
baseVersion: () => authoritative.version,
|
||||
getLayout: () => normalizeLayout(optimistic),
|
||||
dispose() {
|
||||
disposed = true;
|
||||
if (timer !== null) cancel(timer);
|
||||
timer = null;
|
||||
pending = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.CodemanTabLayout = {
|
||||
normalizeLayout,
|
||||
hasGroups,
|
||||
@@ -347,6 +725,12 @@
|
||||
hiddenGroupAlerts,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
applyOperation,
|
||||
moveDestination,
|
||||
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
|
||||
dropOperation,
|
||||
contentKey,
|
||||
createEditCoordinator,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
|
||||
@@ -325,6 +325,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).
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
|
||||
@@ -451,8 +451,16 @@ describe('browser wiring', () => {
|
||||
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
|
||||
});
|
||||
|
||||
it('never writes the layout from the browser in this slice', () => {
|
||||
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
|
||||
it('writes the layout only through the edit coordinator (and its keepalive twin)', () => {
|
||||
// Exactly two PUT sites: _putTabLayout (the coordinator's transport) and the
|
||||
// pagehide keepalive. Anything else would bypass serialization.
|
||||
const writes = APP_SOURCE.split('\n').filter(
|
||||
(line) => line.includes("'/api/tab-layout'") && !line.includes("_apiJson('/api/tab-layout')")
|
||||
);
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(APP_SOURCE).toContain("this._api('/api/tab-layout', { method: 'PUT', body })");
|
||||
expect(APP_SOURCE).toContain("void fetch('/api/tab-layout', {");
|
||||
// The pure module never does IO itself.
|
||||
expect(SOURCE).not.toContain('fetch(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @fileoverview Real-Chromium coverage for editing the grouped vertical rail.
|
||||
*
|
||||
* What DOM emulation cannot answer: a pointer drag (hit testing, capture, the
|
||||
* click that ends a drag), whether the inline group editor actually paints
|
||||
* inside the rail's nowrap/ellipsis header, and whether Escape on an open group
|
||||
* menu reaches the menu first. 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 LAYOUT = {
|
||||
version: 4,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
groups: [
|
||||
{
|
||||
id: 'gx',
|
||||
name: 'Core',
|
||||
refs: [
|
||||
{ kind: 'session', id: 'two' },
|
||||
{ kind: 'webview', id: 'web' },
|
||||
],
|
||||
},
|
||||
{ id: 'gy', name: 'Later', refs: [{ kind: 'session', id: 'three' }] },
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 'one' }],
|
||||
};
|
||||
|
||||
describe('grouped rail editing 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>
|
||||
<main class="main" style="width:100%;height:760px">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation" style="width:280px"></aside>
|
||||
<button id="elsewhere">elsewhere</button>
|
||||
</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: 'idle' }],
|
||||
['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.closeAllPanels = () => {
|
||||
w.__panelsClosed = true;
|
||||
};
|
||||
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());
|
||||
|
||||
beforeEach(async () => {
|
||||
puts = [];
|
||||
await page.evaluate((layout) => {
|
||||
const w = window as any;
|
||||
document.getElementById('sessionTabs')?.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.activeSessionId = 'one';
|
||||
w.__app.activeWebviewId = null;
|
||||
// The rail was just replaced: force the rebuild a skipped no-op would miss.
|
||||
w.__app._lastTabGroupStructureKey = null;
|
||||
w.__app._applyTabLayout(layout);
|
||||
w.__activation = null;
|
||||
w.__panelsClosed = false;
|
||||
}, LAYOUT);
|
||||
await page.mouse.move(1, 1);
|
||||
});
|
||||
|
||||
const box = async (selector: string) => (await page.locator(selector).boundingBox())!;
|
||||
async function drag(from: string, to: string, yFraction = 0.5) {
|
||||
const a = await box(from);
|
||||
const b = await box(to);
|
||||
await page.mouse.move(a.x + 20, a.y + a.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(a.x + 24, a.y + a.height / 2 + 8, { steps: 3 });
|
||||
await page.mouse.move(b.x + 30, b.y + b.height * yFraction, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
const settled = async () => {
|
||||
await page.waitForTimeout(80);
|
||||
await page.evaluate(() => new Promise((r) => setTimeout(r, 20)));
|
||||
};
|
||||
|
||||
it('drags a row from Ungrouped onto a group header: one PUT appending it there', async () => {
|
||||
await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]');
|
||||
await settled();
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0].baseVersion).toBe(4);
|
||||
expect(puts[0].layout.groups[1].refs).toEqual([
|
||||
{ kind: 'session', id: 'three' },
|
||||
{ kind: 'session', id: 'one' },
|
||||
]);
|
||||
expect(puts[0].layout.ungrouped).toEqual([]);
|
||||
// The click that ends a drag neither selected the row nor toggled the header.
|
||||
expect(await page.evaluate(() => (window as any).__activation)).toBeNull();
|
||||
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
|
||||
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('drags a row between two rows of another group (upper half = before)', async () => {
|
||||
await drag('.session-tab[data-id="three"]', '.session-tab[data-webview-id="web"]', 0.25);
|
||||
await settled();
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0].layout.groups[0].refs.map((r: any) => r.id)).toEqual(['two', 'three', 'web']);
|
||||
expect(puts[0].layout.groups[1].refs).toEqual([]);
|
||||
});
|
||||
|
||||
it('drags a group header above another group: a reorder', async () => {
|
||||
await drag('[data-tab-group-header="gy"]', '[data-tab-group-header="gx"]');
|
||||
await settled();
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0].layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']);
|
||||
expect(await page.locator('.tab-layout-group').first().getAttribute('data-tab-group-id')).toBe('gy');
|
||||
});
|
||||
|
||||
it('Escape mid-drag cancels it without a write; a plain click still selects', async () => {
|
||||
const a = await box('.session-tab[data-id="one"]');
|
||||
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: 6 });
|
||||
expect(await page.locator('.tab-layout-drop-into').count()).toBe(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.up();
|
||||
await settled();
|
||||
expect(puts).toHaveLength(0);
|
||||
|
||||
await page.locator('.session-tab[data-id="two"] .tab-name').click();
|
||||
expect(await page.evaluate(() => (window as any).__activation)).toBe('session:two');
|
||||
});
|
||||
|
||||
it('paints the inline group editor as you type, then saves the trimmed name', async () => {
|
||||
await page.evaluate(() => (window as any).__app.startTabGroupRename('gx'));
|
||||
const input = page.locator('.tab-layout-group-rename-input');
|
||||
await expect.poll(() => input.evaluate((el) => el === document.activeElement)).toBe(true);
|
||||
await page.keyboard.press('Control+A');
|
||||
await page.keyboard.type('Front end');
|
||||
const paint = await input.evaluate((el: HTMLInputElement) => ({
|
||||
value: el.value,
|
||||
width: el.getBoundingClientRect().width,
|
||||
label: getComputedStyle(el.parentElement!).display,
|
||||
scrollWidth: el.scrollWidth,
|
||||
}));
|
||||
expect(paint.value).toBe('Front end');
|
||||
// The editor gets the header's free width, not a collapsed ellipsis slot.
|
||||
expect(paint.width).toBeGreaterThan(120);
|
||||
expect(paint.label).toBe('flex');
|
||||
await page.keyboard.press('Enter');
|
||||
await settled();
|
||||
expect(puts.at(-1).layout.groups[0].name).toBe('Front end');
|
||||
expect(await page.locator('[data-tab-group-header="gx"] .tab-layout-group-name').textContent()).toBe('Front end');
|
||||
expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gx');
|
||||
});
|
||||
|
||||
it('opens the group menu from the header glyph and Escape closes only it', async () => {
|
||||
await page.locator('[data-tab-group-header="gy"]').hover();
|
||||
await page.locator('[data-tab-group-header="gy"] .tab-layout-group-menu').click();
|
||||
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1);
|
||||
// The glyph opened the menu without toggling the group.
|
||||
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (document.activeElement as HTMLElement)?.dataset?.tabGroupHeader)).toBe('gy');
|
||||
|
||||
await page.locator('[data-tab-group-header="gy"]').click({ button: 'right' });
|
||||
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(1);
|
||||
await page.locator('#elsewhere').click();
|
||||
expect(await page.locator('.tab-layout-group-action-menu').count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,867 @@
|
||||
/**
|
||||
* @fileoverview Editing the grouped vertical rail from the browser.
|
||||
*
|
||||
* Two halves:
|
||||
* - the pure operation mirror + edit coordinator in tab-layout-browser.js
|
||||
* (vm-loaded): each named operation, the PUT payload and version, ONE write
|
||||
* in flight at a time, a 409 rebased onto the server's layout, and the
|
||||
* drag-drop -> operation mapping;
|
||||
* - the app.js wiring driven through the shipping CodemanApp inside JSDOM:
|
||||
* row and group menus, inline group rename, menu dismissal, SSE deferral
|
||||
* while a write is in flight, recovery of unsaved edits across a reload, and
|
||||
* the flat rail staying byte-identical when no group exists.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
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' };
|
||||
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(read('tab-layout-browser.js'), context, { filename: 'tab-layout-browser.js' });
|
||||
return (context.window as any).CodemanTabLayout;
|
||||
}
|
||||
|
||||
const s = (id: string): Ref => ({ kind: 'session', id });
|
||||
const w = (id: string): Ref => ({ kind: 'webview', id });
|
||||
const base = (version = 5): Layout => ({
|
||||
version,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core', refs: [s('a'), s('b')] },
|
||||
{ id: 'g2', name: 'Ops', refs: [w('web')] },
|
||||
],
|
||||
ungrouped: [s('c'), s('d')],
|
||||
});
|
||||
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
const keys = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`);
|
||||
|
||||
describe('operations', () => {
|
||||
const h = loadHelper();
|
||||
|
||||
it('creates, renames, reorders and deletes groups without mutating the input', () => {
|
||||
const input = base();
|
||||
let next = h.applyOperation(input, { type: 'createGroup', id: 'g3', name: ' New ', index: 1 });
|
||||
expect(next.groups.map((g: any) => [g.id, g.name])).toEqual([
|
||||
['g1', 'Core'],
|
||||
['g3', 'New'],
|
||||
['g2', 'Ops'],
|
||||
]);
|
||||
// Replaying a create (recovery after reload) is a no-op, not a duplicate.
|
||||
expect(plain(h.applyOperation(next, { type: 'createGroup', id: 'g3', name: 'New' }))).toEqual(plain(next));
|
||||
next = h.applyOperation(next, { type: 'renameGroup', groupId: 'g3', name: 'Renamed' });
|
||||
expect(next.groups[1].name).toBe('Renamed');
|
||||
next = h.applyOperation(next, { type: 'reorderGroup', groupId: 'g1', index: 2 });
|
||||
expect(next.groups.map((g: any) => g.id)).toEqual(['g3', 'g2', 'g1']);
|
||||
next = h.applyOperation(next, { type: 'deleteGroup', groupId: 'g1' });
|
||||
expect(next.groups.map((g: any) => g.id)).toEqual(['g3', 'g2']);
|
||||
// Its rows return to Ungrouped, in order, after what was already there.
|
||||
expect(keys(next.ungrouped)).toEqual(['session:c', 'session:d', 'session:a', 'session:b']);
|
||||
expect(plain(input)).toEqual(plain(base()));
|
||||
});
|
||||
|
||||
it('rejects an operation the server would refuse, before any PUT', () => {
|
||||
expect(() => h.applyOperation(base(), { type: 'createGroup', id: 'x', name: ' ' })).toThrow();
|
||||
expect(() => h.applyOperation(base(), { type: 'createGroup', id: 'x', name: 'n'.repeat(61) })).toThrow();
|
||||
expect(() => h.applyOperation(base(), { type: 'renameGroup', groupId: 'nope', name: 'x' })).toThrow();
|
||||
expect(() => h.applyOperation(base(), { type: 'moveRef', ref: s('zz'), groupId: null, index: 0 })).toThrow();
|
||||
expect(() => h.applyOperation(base(), { type: 'moveRef', ref: s('a'), groupId: 'nope', index: 0 })).toThrow();
|
||||
const full = { ...base(), groups: Array.from({ length: 32 }, (_, i) => ({ id: `g${i}`, name: 'x', refs: [] })) };
|
||||
expect(() => h.applyOperation(full, { type: 'createGroup', id: 'one-more', name: 'x' })).toThrow(/limit/);
|
||||
});
|
||||
|
||||
it('moves a row with the sessions that follow it, and marks a hand-moved child manual', () => {
|
||||
const layout = { ...base(), ungrouped: [s('c'), s('child'), s('d')] };
|
||||
const parents = { child: 'c' };
|
||||
const moved = h.applyOperation(layout, { type: 'moveRef', ref: s('c'), groupId: 'g1', index: 1, parents });
|
||||
expect(keys(moved.groups[0].refs)).toEqual(['session:a', 'session:c', 'session:child', 'session:b']);
|
||||
expect(keys(moved.ungrouped)).toEqual(['session:d']);
|
||||
const child = h.applyOperation(layout, { type: 'moveRef', ref: s('child'), groupId: 'g2', index: 0, parents });
|
||||
expect(child.groups[1].refs[0]).toEqual({ kind: 'session', id: 'child', placement: 'manual' });
|
||||
// ...and manual placement survives normalization (it is written back whole).
|
||||
expect(h.normalizeLayout(child).groups[1].refs[0].placement).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drop -> operation', () => {
|
||||
const h = loadHelper();
|
||||
|
||||
it('maps a row dropped before/after a row, onto a header, and onto Ungrouped', () => {
|
||||
expect(
|
||||
h.dropOperation(
|
||||
base(),
|
||||
{ type: 'ref', ref: s('c') },
|
||||
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'before' },
|
||||
{}
|
||||
)
|
||||
).toEqual({
|
||||
type: 'moveRef',
|
||||
ref: s('c'),
|
||||
groupId: 'g1',
|
||||
index: 1,
|
||||
parents: {},
|
||||
});
|
||||
expect(
|
||||
h.dropOperation(
|
||||
base(),
|
||||
{ type: 'ref', ref: s('c') },
|
||||
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'after' },
|
||||
{}
|
||||
).index
|
||||
).toBe(2);
|
||||
// Within the same container the index counts AFTER the moved row is taken out.
|
||||
expect(
|
||||
h.dropOperation(
|
||||
base(),
|
||||
{ type: 'ref', ref: s('a') },
|
||||
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'after' },
|
||||
{}
|
||||
)
|
||||
).toMatchObject({
|
||||
groupId: 'g1',
|
||||
index: 1,
|
||||
});
|
||||
expect(h.dropOperation(base(), { type: 'ref', ref: w('web') }, { type: 'group', groupId: 'g1' }, {})).toMatchObject(
|
||||
{
|
||||
ref: w('web'),
|
||||
groupId: 'g1',
|
||||
index: 2,
|
||||
}
|
||||
);
|
||||
expect(h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'ungrouped' }, {})).toMatchObject({
|
||||
groupId: null,
|
||||
index: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a group dropped on another group (or a row in it) to a reorder', () => {
|
||||
expect(h.dropOperation(base(), { type: 'group', groupId: 'g2' }, { type: 'group', groupId: 'g1' }, {})).toEqual({
|
||||
type: 'reorderGroup',
|
||||
groupId: 'g2',
|
||||
index: 0,
|
||||
});
|
||||
expect(
|
||||
h.dropOperation(
|
||||
base(),
|
||||
{ type: 'group', groupId: 'g1' },
|
||||
{ type: 'ref', ref: w('web'), groupId: 'g2', placement: 'before' },
|
||||
{}
|
||||
)
|
||||
).toEqual({
|
||||
type: 'reorderGroup',
|
||||
groupId: 'g1',
|
||||
index: 1,
|
||||
});
|
||||
expect(h.dropOperation(base(), { type: 'group', groupId: 'g1' }, { type: 'ungrouped' }, {})).toMatchObject({
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a drop that changes nothing', () => {
|
||||
expect(
|
||||
h.dropOperation(
|
||||
base(),
|
||||
{ type: 'ref', ref: s('a') },
|
||||
{ type: 'ref', ref: s('a'), groupId: 'g1', placement: 'after' },
|
||||
{}
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
h.dropOperation(
|
||||
base(),
|
||||
{ type: 'ref', ref: s('a') },
|
||||
{ type: 'ref', ref: s('b'), groupId: 'g1', placement: 'before' },
|
||||
{}
|
||||
)
|
||||
).toBeNull();
|
||||
expect(h.dropOperation(base(), { type: 'ref', ref: s('a') }, { type: 'group', groupId: 'g1' }, {})).toBeNull();
|
||||
expect(h.dropOperation(base(), { type: 'ref', ref: s('c') }, { type: 'ungrouped' }, {})).toBeNull();
|
||||
expect(h.dropOperation(base(), { type: 'group', groupId: 'g1' }, { type: 'group', groupId: 'g1' }, {})).toBeNull();
|
||||
// Onto its own following child: the child moves with it, so there is no slot.
|
||||
const layout = { ...base(), ungrouped: [s('c'), s('child')] };
|
||||
expect(
|
||||
h.dropOperation(
|
||||
layout,
|
||||
{ type: 'ref', ref: s('c') },
|
||||
{ type: 'ref', ref: s('child'), groupId: null, placement: 'after' },
|
||||
{ child: 'c' }
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit coordinator', () => {
|
||||
const h = loadHelper();
|
||||
|
||||
/** A put() whose responses the test resolves by hand, in order. */
|
||||
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 };
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
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('applies at once and PUTs exactly { baseVersion, layout } with the held version', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor, applied } = makeEditor(put);
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Front' });
|
||||
expect(applied.at(-1).layout.groups[0].name).toBe('Front');
|
||||
expect(applied.at(-1).meta).toEqual({ optimistic: true });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(Object.keys(calls[0].request).sort()).toEqual(['baseVersion', 'layout']);
|
||||
expect(calls[0].request.baseVersion).toBe(5);
|
||||
expect(calls[0].request.layout.groups[0].name).toBe('Front');
|
||||
calls[0].resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
layout: { ...base(6), groups: [{ ...base().groups[0], name: 'Front' }, base().groups[1]] },
|
||||
});
|
||||
await settle();
|
||||
expect(editor.hasPending()).toBe(false);
|
||||
expect(editor.baseVersion()).toBe(6);
|
||||
});
|
||||
|
||||
it('batches edits made in one turn into a single PUT', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor } = makeEditor(put);
|
||||
editor.enqueue({ type: 'createGroup', id: 'g3', name: 'Three' });
|
||||
editor.enqueue({ type: 'moveRef', ref: s('c'), groupId: 'g3', index: 0 });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(keys(calls[0].request.layout.groups[2].refs)).toEqual(['session:c']);
|
||||
});
|
||||
|
||||
it('keeps ONE write in flight and sends later edits on the version it returns', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor } = makeEditor(put);
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'First' });
|
||||
await settle();
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Second' });
|
||||
await settle();
|
||||
await settle();
|
||||
// The second edit waits: no concurrent PUT racing the first.
|
||||
expect(calls).toHaveLength(1);
|
||||
const confirmed = plain(calls[0].request.layout);
|
||||
calls[0].resolve({ ok: true, status: 200, layout: { ...confirmed, version: 6 } });
|
||||
await settle();
|
||||
await settle();
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].request.baseVersion).toBe(6);
|
||||
expect(calls[1].request.layout.groups.map((g: any) => g.name)).toEqual(['First', 'Second']);
|
||||
calls[1].resolve({ ok: true, status: 200, layout: { ...plain(calls[1].request.layout), version: 7 } });
|
||||
await settle();
|
||||
expect(editor.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('rebases a 409 onto the server layout, keeping the concurrent edit, and retries once', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor, applied } = makeEditor(put);
|
||||
editor.enqueue({ type: 'moveRef', ref: s('c'), groupId: 'g1', index: 2 });
|
||||
await settle();
|
||||
expect(calls[0].request.baseVersion).toBe(5);
|
||||
// Elsewhere, someone created a group and renamed Ops (version 9).
|
||||
const server: Layout = {
|
||||
...base(9),
|
||||
groups: [
|
||||
...base().groups.map((g) => (g.id === 'g2' ? { ...g, name: 'Ops!' } : g)),
|
||||
{ id: 'gx', name: 'Theirs', refs: [] },
|
||||
],
|
||||
};
|
||||
calls[0].resolve({ ok: false, status: 409, layout: server });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].request.baseVersion).toBe(9);
|
||||
const retried = calls[1].request.layout;
|
||||
expect(retried.groups.map((g: any) => g.name)).toEqual(['Core', 'Ops!', 'Theirs']);
|
||||
expect(keys(retried.groups[0].refs)).toEqual(['session:a', 'session:b', 'session:c']);
|
||||
calls[1].resolve({ ok: true, status: 200, layout: { ...retried, version: 10 } });
|
||||
await settle();
|
||||
expect(applied.at(-1).meta).toEqual({ authoritative: true });
|
||||
expect(applied.at(-1).layout.version).toBe(10);
|
||||
expect(editor.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops (and reports) an edit the conflicting layout no longer supports', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const reportError = vi.fn();
|
||||
const { editor, applied } = makeEditor(put, { reportError });
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g2', name: 'Mine' });
|
||||
await settle();
|
||||
const server: Layout = { ...base(9), groups: [base().groups[0]], ungrouped: [...base().ungrouped, w('web')] };
|
||||
calls[0].resolve({ ok: false, status: 409, layout: server });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(reportError).toHaveBeenCalledTimes(1);
|
||||
expect(applied.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['g1']);
|
||||
expect(editor.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('gives up after bounded conflicts and asks the caller to re-read', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const onFailure = vi.fn();
|
||||
const reportError = vi.fn();
|
||||
const { editor } = makeEditor(put, { onFailure, reportError, maxAttempts: 2 });
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' });
|
||||
await settle();
|
||||
calls[0].resolve({ ok: false, status: 409, layout: base(7) });
|
||||
await settle();
|
||||
calls[1].resolve({ ok: false, status: 409, layout: base(8) });
|
||||
await settle();
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(onFailure).toHaveBeenCalledTimes(1);
|
||||
expect(reportError).toHaveBeenCalledTimes(1);
|
||||
expect(editor.getLayout().groups[0].name).toBe('Core');
|
||||
});
|
||||
|
||||
it('re-reads and rebases on a 400, and reports a network failure', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const fetchLayout = vi.fn(async () => base(11));
|
||||
const onFailure = vi.fn();
|
||||
const { editor } = makeEditor(put, { fetchLayout, onFailure, reportError: vi.fn() });
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' });
|
||||
await settle();
|
||||
calls[0].resolve({ ok: false, status: 400, layout: null });
|
||||
await settle();
|
||||
await settle();
|
||||
expect(fetchLayout).toHaveBeenCalledTimes(1);
|
||||
expect(calls[1].request.baseVersion).toBe(11);
|
||||
calls[1].resolve({ ok: false, status: 0, layout: null });
|
||||
await settle();
|
||||
expect(onFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses an external layout while writing, rebases pending edits onto one otherwise', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor, applied } = makeEditor(put);
|
||||
editor.enqueue({ type: 'renameGroup', groupId: 'g1', name: 'Mine' });
|
||||
await settle();
|
||||
expect(editor.adoptExternal(base(20))).toBe(false);
|
||||
calls[0].resolve({ ok: true, status: 200, layout: { ...plain(calls[0].request.layout), version: 6 } });
|
||||
await settle();
|
||||
expect(editor.adoptExternal(base(4))).toBe(false); // older than what we hold
|
||||
const external = { ...base(21), groups: [...base().groups, { id: 'gx', name: 'X', refs: [] }] };
|
||||
expect(editor.adoptExternal(external)).toBe(true);
|
||||
expect(applied.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['g1', 'g2', 'gx']);
|
||||
});
|
||||
|
||||
it('restores recovered edits only when they still change something', async () => {
|
||||
const { put, calls } = controlledPut();
|
||||
const { editor } = makeEditor(put);
|
||||
expect(editor.restore([{ type: 'renameGroup', groupId: 'g1', name: 'Core' }])).toBe(false);
|
||||
expect(editor.restore([{ type: 'renameGroup', groupId: 'gone', name: 'x' }])).toBe(false);
|
||||
expect(editor.restore([{ type: 'renameGroup', groupId: 'g1', name: 'Again' }])).toBe(true);
|
||||
await settle();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(editor.pendingOperations()).toEqual([{ type: 'renameGroup', groupId: 'g1', name: 'Again' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── app.js wiring ─────────────────────────────────────────────────────
|
||||
|
||||
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.__EditCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = win.__EditCodemanApp;
|
||||
});
|
||||
|
||||
const serverLayout = (version = 8): Layout => ({
|
||||
version,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
groups: [
|
||||
{ id: 'gx', name: '<Core & Ops>', refs: [s('s2'), w('w1')] },
|
||||
{ id: 'gy', name: 'Later', refs: [] },
|
||||
],
|
||||
ungrouped: [s('s1'), s('s3')],
|
||||
});
|
||||
|
||||
/** fetch stub: records PUT bodies, answers each with the next queued response. */
|
||||
function installFetch(responses: Array<(body: any) => { status: number; body: unknown }> = []) {
|
||||
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 next =
|
||||
responses.shift() ||
|
||||
((b: any) => ({
|
||||
status: 200,
|
||||
body: { success: true, data: { layout: { ...b.layout, version: b.baseVersion + 1 } } },
|
||||
}));
|
||||
const { status, body: payload } = next(body);
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => payload };
|
||||
});
|
||||
return puts;
|
||||
}
|
||||
|
||||
function makeApp(layout: unknown = serverLayout()) {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
|
||||
document.documentElement.dataset.tabRailSort = 'manual';
|
||||
document.body.innerHTML = '<div id="sessionTabs" role="tablist" aria-label="Session tabs"></div>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['s1', { id: 's1', name: 'One', status: 'idle' }],
|
||||
['s2', { id: 's2', name: 'Two', 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 = () => {};
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app._startSidebarRichClock = () => {};
|
||||
app._stopSidebarRichClock = () => {};
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.openSessionOptions = vi.fn();
|
||||
app.requestCloseSession = vi.fn();
|
||||
app.selectSession = vi.fn();
|
||||
app.showWebviewModal = vi.fn();
|
||||
app.showToast = vi.fn();
|
||||
app.closeAllPanels = vi.fn();
|
||||
// The page renders the flat rail before the first layout read lands.
|
||||
app._fullRenderSessionTabs();
|
||||
if (layout) app._applyTabLayout(layout);
|
||||
return app;
|
||||
}
|
||||
|
||||
const tabs = () => document.getElementById('sessionTabs')!;
|
||||
const header = (id: string) => document.querySelector<HTMLElement>(`[data-tab-group-header="${id}"]`)!;
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
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 key = (target: Element, k: string, init: Record<string, unknown> = {}) =>
|
||||
target.dispatchEvent(new win.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
win.localStorage.clear();
|
||||
win.sessionStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.tab-rail-action-menu').forEach((menu) => menu.remove());
|
||||
});
|
||||
|
||||
describe('row actions in the vertical rail', () => {
|
||||
it('offers group moves (and only "new group" before any group exists), never on the strip', () => {
|
||||
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']);
|
||||
flat.closeTabRailActionMenu();
|
||||
|
||||
const app = makeApp();
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
|
||||
expect(menuLabels()).toEqual([
|
||||
'Session options',
|
||||
'Move down',
|
||||
'Move to Later',
|
||||
'Move to Ungrouped',
|
||||
'Move to new group',
|
||||
'Close session',
|
||||
]);
|
||||
app.closeTabRailActionMenu();
|
||||
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app._fullRenderSessionTabs();
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s2') }, 's2');
|
||||
expect(menuLabels()).toEqual(['Session options', 'Close session']);
|
||||
});
|
||||
|
||||
it('moves a row into another group with one PUT carrying the held version', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp();
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
|
||||
clickMenu('Move to Later');
|
||||
// Optimistic: the rail already shows it there.
|
||||
expect(row('s1').closest('.tab-layout-group')!.getAttribute('data-tab-group-id')).toBe('gy');
|
||||
await flush();
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0].baseVersion).toBe(8);
|
||||
expect(puts[0].layout.groups[1].refs).toEqual([s('s1')]);
|
||||
expect(keys(puts[0].layout.ungrouped)).toEqual(['session:s3']);
|
||||
expect(typeof puts[0].layout.updatedAt).toBe('string');
|
||||
expect(app.tabLayout.version).toBe(9);
|
||||
// Focus lands on the moved row, so a keyboard user stays in the rail.
|
||||
expect(document.activeElement).toBe(row('s1'));
|
||||
});
|
||||
|
||||
it('creates the first group from a flat rail and goes straight into renaming it', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s3') }, 's3');
|
||||
clickMenu('Move to new group');
|
||||
// The rail is now grouped, with an editor in the new header.
|
||||
expect(tabs().getAttribute('role')).toBe('tree');
|
||||
const input = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
|
||||
expect(input).not.toBeNull();
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.value).toBe('New group');
|
||||
input.value = ' Build <x> ';
|
||||
key(input, 'Enter');
|
||||
await flush();
|
||||
// Create, move and the rename made in the same turn share ONE write.
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0].baseVersion).toBe(8);
|
||||
expect(puts[0].layout.groups).toHaveLength(1);
|
||||
expect(puts[0].layout.groups[0].name).toBe('Build <x>');
|
||||
expect(puts[0].layout.groups[0].refs).toEqual([s('s3')]);
|
||||
// The name is text, never markup.
|
||||
const name = header(puts[0].layout.groups[0].id).querySelector('.tab-layout-group-name')!;
|
||||
expect(name.textContent).toBe('Build <x>');
|
||||
expect(name.children).toHaveLength(0);
|
||||
// A later rename goes out on the version that write returned.
|
||||
app.startTabGroupRename(puts[0].layout.groups[0].id);
|
||||
const again = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
|
||||
again.value = 'Build';
|
||||
key(again, 'Enter');
|
||||
await flush();
|
||||
expect(puts).toHaveLength(2);
|
||||
expect(puts[1].baseVersion).toBe(9);
|
||||
expect(puts[1].layout.groups[0].name).toBe('Build');
|
||||
expect(app._inlineRenameActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web tab rows', () => {
|
||||
it('Shift+F10 on a web tab offers its settings and the same group moves', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp();
|
||||
const web = document.querySelector<HTMLElement>('.session-tab[data-webview-id="w1"]')!;
|
||||
web.focus();
|
||||
key(web, 'F10', { shiftKey: true });
|
||||
expect(menuLabels()).toEqual([
|
||||
'Web tab settings',
|
||||
'Move up',
|
||||
'Move to Later',
|
||||
'Move to Ungrouped',
|
||||
'Move to new group',
|
||||
]);
|
||||
clickMenu('Move to Ungrouped');
|
||||
await flush();
|
||||
expect(keys(puts[0].layout.ungrouped)).toEqual(['session:s1', 'session:s3', 'webview:w1']);
|
||||
expect(document.activeElement).toBe(document.querySelector('.session-tab[data-webview-id="w1"]'));
|
||||
expect(app.showWebviewModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves down past the sessions that follow the moved one', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp({
|
||||
...serverLayout(),
|
||||
groups: [{ id: 'gx', name: 'G', refs: [s('s1'), s('s2'), s('s3')] }],
|
||||
ungrouped: [],
|
||||
});
|
||||
app.sessions.get('s2').parentSessionId = 's1';
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: row('s1') }, 's1');
|
||||
clickMenu('Move down');
|
||||
await flush();
|
||||
expect(keys(puts[0].layout.groups[0].refs)).toEqual(['session:s3', 'session:s1', 'session:s2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group menu', () => {
|
||||
it('opens from Shift+F10 / ContextMenu on a header and runs each group operation', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp();
|
||||
win.confirm = vi.fn(() => true);
|
||||
header('gx').focus();
|
||||
key(header('gx'), 'F10', { shiftKey: true });
|
||||
expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group down', 'Delete group']);
|
||||
expect(document.activeElement?.textContent).toBe('Rename group');
|
||||
clickMenu('Move group down');
|
||||
await flush();
|
||||
expect(puts.at(-1).layout.groups.map((g: any) => g.id)).toEqual(['gy', 'gx']);
|
||||
expect(document.activeElement).toBe(header('gx'));
|
||||
|
||||
key(header('gx'), 'ContextMenu');
|
||||
expect(menuLabels()).toEqual(['Rename group', 'New group', 'Move group up', 'Delete group']);
|
||||
clickMenu('Delete group');
|
||||
expect(win.confirm).toHaveBeenCalledWith('Delete group "<Core & Ops>"? Its tabs move to Ungrouped.');
|
||||
await flush();
|
||||
const last = puts.at(-1).layout;
|
||||
expect(last.groups.map((g: any) => g.id)).toEqual(['gy']);
|
||||
expect(keys(last.ungrouped)).toEqual(['session:s1', 'session:s3', 'session:s2', 'webview:w1']);
|
||||
});
|
||||
|
||||
it('renames from F2 and cancels on Escape without a write', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp();
|
||||
header('gy').focus();
|
||||
key(header('gy'), 'F2');
|
||||
const input = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
|
||||
input.value = 'Changed';
|
||||
key(input, 'Escape');
|
||||
await flush();
|
||||
expect(puts).toHaveLength(0);
|
||||
expect(header('gy').querySelector('.tab-layout-group-name')!.textContent).toBe('Later');
|
||||
expect(document.activeElement).toBe(header('gy'));
|
||||
expect(app._inlineRenameActive).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores IME composition keys and blocks re-renders while editing', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp();
|
||||
app.startTabGroupRename('gy');
|
||||
const input = document.querySelector<HTMLInputElement>('.tab-layout-group-rename-input')!;
|
||||
input.value = '组';
|
||||
key(input, 'Enter', { isComposing: true });
|
||||
expect(document.querySelector('.tab-layout-group-rename-input')).toBe(input);
|
||||
app._fullRenderSessionTabs(); // a background render must not destroy the editor
|
||||
expect(input.isConnected).toBe(true);
|
||||
input.blur();
|
||||
await flush();
|
||||
expect(puts.at(-1).layout.groups[1].name).toBe('组');
|
||||
});
|
||||
|
||||
it('a session rename takes the editor over and a stale group editor cannot release the guard', () => {
|
||||
installFetch();
|
||||
const app = makeApp();
|
||||
app.startTabGroupRename('gy');
|
||||
const handle = app._activeRename;
|
||||
expect(handle.groupId).toBe('gy');
|
||||
const other = { cancel: vi.fn() };
|
||||
app._activeRename = other; // someone newer owns the guard
|
||||
handle.cancel();
|
||||
expect(app._inlineRenameActive).toBe(true);
|
||||
});
|
||||
|
||||
describe('dismissal', () => {
|
||||
const open = (app: Record<string, any>) => {
|
||||
header('gx').focus();
|
||||
key(header('gx'), 'F10', { shiftKey: true });
|
||||
expect(document.querySelector('.tab-layout-group-action-menu')).not.toBeNull();
|
||||
};
|
||||
const isOpen = () => document.querySelector('.tab-layout-group-action-menu') !== null;
|
||||
|
||||
it('Escape closes only the menu and returns focus to its header', () => {
|
||||
installFetch();
|
||||
const app = makeApp();
|
||||
open(app);
|
||||
const escape = new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||
document.activeElement!.dispatchEvent(escape);
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(document.activeElement).toBe(header('gx'));
|
||||
expect(app._tabGroupMenuKeydown).toBeNull();
|
||||
});
|
||||
|
||||
it('a pointer outside, Tab, focus leaving, a resize, a second open and a re-render all close it', () => {
|
||||
installFetch();
|
||||
const app = makeApp();
|
||||
open(app);
|
||||
document.body.dispatchEvent(new win.Event('pointerdown', { bubbles: true }));
|
||||
expect(isOpen()).toBe(false);
|
||||
|
||||
open(app);
|
||||
// A pointer INSIDE the menu does not close it.
|
||||
document
|
||||
.querySelector('.tab-layout-group-action-menu button')!
|
||||
.dispatchEvent(new win.Event('pointerdown', { bubbles: true }));
|
||||
expect(isOpen()).toBe(true);
|
||||
key(document.activeElement!, 'Tab');
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(document.activeElement).toBe(header('gx'));
|
||||
|
||||
open(app);
|
||||
const buttons = document.querySelectorAll<HTMLElement>('.tab-layout-group-action-menu button');
|
||||
buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: buttons[1] }));
|
||||
expect(isOpen()).toBe(true);
|
||||
buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: document.body }));
|
||||
expect(isOpen()).toBe(false);
|
||||
|
||||
open(app);
|
||||
win.dispatchEvent(new win.Event('resize'));
|
||||
expect(isOpen()).toBe(false);
|
||||
|
||||
open(app);
|
||||
app.openTabGroupMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: header('gx') }, 'gx');
|
||||
expect(isOpen()).toBe(false);
|
||||
|
||||
open(app);
|
||||
app._fullRenderSessionTabs();
|
||||
expect(isOpen()).toBe(false);
|
||||
// No listener is left behind by any of the paths.
|
||||
expect(app._tabGroupMenuOutside).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('server echoes and reloads', () => {
|
||||
it('defers an SSE-triggered read while its own write is in flight, then re-reads once', async () => {
|
||||
let releasePut: (() => void) | null = null;
|
||||
const gets: number[] = [];
|
||||
const puts: any[] = [];
|
||||
win.fetch = vi.fn(async (_url: string, init: any) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(init.body);
|
||||
puts.push(body);
|
||||
await new Promise<void>((resolve) => (releasePut = resolve));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, data: { layout: { ...body.layout, version: 9 } } }),
|
||||
};
|
||||
}
|
||||
gets.push(1);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, data: { layout: { ...serverLayout(9), groups: puts[0].layout.groups } } }),
|
||||
};
|
||||
});
|
||||
const app = makeApp();
|
||||
app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Mine' });
|
||||
await flush();
|
||||
expect(puts).toHaveLength(1);
|
||||
app._onTabLayoutChanged({ owner: '@single', version: 9 });
|
||||
await flush();
|
||||
expect(gets).toHaveLength(0);
|
||||
releasePut!();
|
||||
await flush();
|
||||
expect(app.tabLayout.groups[1].name).toBe('Mine');
|
||||
expect(gets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps unsaved edits across a reload: keepalive PUT now, rebased replay after', async () => {
|
||||
const puts = installFetch();
|
||||
const app = makeApp();
|
||||
app.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'Unsaved' });
|
||||
// Page goes away before the flush ran (dispose stands in for the unload).
|
||||
app._persistPendingTabLayoutEdits();
|
||||
app._tabLayoutEditor.dispose();
|
||||
expect(win.fetch).toHaveBeenCalledWith(
|
||||
'/api/tab-layout',
|
||||
expect.objectContaining({ method: 'PUT', keepalive: true })
|
||||
);
|
||||
expect(puts.at(-1).baseVersion).toBe(8);
|
||||
expect(puts.at(-1).layout.groups[1].name).toBe('Unsaved');
|
||||
expect(JSON.parse(win.sessionStorage.getItem('codeman:tab-layout-pending')).operations).toEqual([
|
||||
{ type: 'renameGroup', groupId: 'gy', name: 'Unsaved' },
|
||||
]);
|
||||
|
||||
// Next page: the keepalive lost a race; the layout moved on to version 12.
|
||||
const next = installFetch();
|
||||
const reloaded = makeApp({
|
||||
...serverLayout(12),
|
||||
groups: [...serverLayout().groups, { id: 'gz', name: 'Z', refs: [] }],
|
||||
});
|
||||
expect(reloaded.tabLayout.groups[1].name).toBe('Unsaved');
|
||||
await flush();
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0].baseVersion).toBe(12);
|
||||
expect(next[0].layout.groups.map((g: any) => g.name)).toEqual(['<Core & Ops>', 'Unsaved', 'Z']);
|
||||
expect(win.sessionStorage.getItem('codeman:tab-layout-pending')).toBeNull();
|
||||
|
||||
// And when the keepalive DID land, nothing is re-sent.
|
||||
win.sessionStorage.setItem(
|
||||
'codeman:tab-layout-pending',
|
||||
JSON.stringify({ operations: [{ type: 'renameGroup', groupId: 'gy', name: 'Later' }] })
|
||||
);
|
||||
const none = installFetch();
|
||||
makeApp();
|
||||
await flush();
|
||||
expect(none).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves the flat rail byte-identical when the layout has no groups, and never edits off the rail', () => {
|
||||
installFetch();
|
||||
const noLayout = makeApp(null);
|
||||
noLayout._fullRenderSessionTabs();
|
||||
const flat = tabs().innerHTML;
|
||||
makeApp({ ...serverLayout(), groups: [], ungrouped: [s('s1'), s('s2'), s('s3')] });
|
||||
expect(tabs().innerHTML).toBe(flat);
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
const strip = makeApp();
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
expect(strip.editTabLayout({ type: 'renameGroup', groupId: 'gy', name: 'x' })).toBe(false);
|
||||
expect(win.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -727,10 +727,17 @@ describe('grouped rail tree semantics', () => {
|
||||
row('s2').focus();
|
||||
press('F10', { shiftKey: true });
|
||||
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
|
||||
// A web tab's keys open its menu (settings + group moves); settings is one item.
|
||||
row('w1').focus();
|
||||
press('ContextMenu');
|
||||
const settings = [...document.querySelectorAll<HTMLElement>('.tab-layout-group-action-menu button')].find(
|
||||
(button) => button.textContent === 'Web tab settings'
|
||||
)!;
|
||||
settings.click();
|
||||
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
|
||||
row('w1').focus();
|
||||
press('F10');
|
||||
expect(document.querySelector('.tab-layout-group-action-menu')).toBeNull();
|
||||
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user