mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
Merge pull request #525 from aakhter/pr/grouped-rail-edit
feat(tabs): edit groups in the vertical rail
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. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. 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 once (a second 400 is reported as a failed save, not as a race); 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. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last.
|
||||
- **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 (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. 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. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. 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).
|
||||
|
||||
|
||||
+699
-16
@@ -577,14 +577,23 @@ const SIDEBAR_RICH_CLOCK_MS = 20000;
|
||||
*/
|
||||
const URL_SESSION_WAIT_MS = 30000;
|
||||
|
||||
/**
|
||||
* How old the sessionStorage copy of unsaved tab-group edits may be when the
|
||||
* next page replays it (see _restorePendingTabLayoutEdits). A reload takes
|
||||
* seconds; an older copy is from a tab that sat closed or a different visit.
|
||||
*/
|
||||
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
|
||||
|
||||
class CodemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
||||
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
||||
this.draggedTabId = null; // Currently dragged tab session ID
|
||||
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
|
||||
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
|
||||
// Owner tab layout: read via GET /api/tab-layout and edited from the vertical
|
||||
// rail via PUT /api/tab-layout (editTabLayout). It decides how the rail GROUPS
|
||||
// rows; the server projects it onto the session order, so sessionOrder above
|
||||
// stays the tab order.
|
||||
this.tabLayout = null;
|
||||
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||
@@ -866,6 +875,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 +1278,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 +5716,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 +5747,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 +5912,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 +6239,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;
|
||||
}
|
||||
@@ -6312,7 +6344,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: grouped vertical rail (read-only)
|
||||
// Owner tab layout: grouped vertical rail (reading and drawing)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
|
||||
@@ -6320,7 +6352,7 @@ class CodemanApp {
|
||||
// Ctrl+Tab and every other order consumer are untouched here. This layer only
|
||||
// decides how the VERTICAL rail draws rows: in sections, with per-device
|
||||
// collapse. With no groups (or any read failure) the rail is the flat list it
|
||||
// has always been.
|
||||
// has always been. Editing the groups is the next block.
|
||||
|
||||
_ensureTabLayoutCoordinator() {
|
||||
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
|
||||
@@ -6356,6 +6388,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 +6410,27 @@ 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) {
|
||||
// A failed read says nothing about the layout, and dropping the editor now
|
||||
// would lose the edit outright: a write in flight would never get its 409
|
||||
// rebased. Keep the held layout and the editor; read again once it settles.
|
||||
if (!next && editor.hasPending()) {
|
||||
this._tabLayoutReloadPending = true;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
if (editor.hasPending()) this.showToast?.('Your tab group edit was not saved.', 'error');
|
||||
editor.dispose();
|
||||
this._tabLayoutEditor = null;
|
||||
}
|
||||
this.tabLayout = next;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const collapsed = storage && next
|
||||
@@ -6384,6 +6443,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 +6510,623 @@ 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.`)) {
|
||||
// The menu that asked is gone; put the keyboard back on the group.
|
||||
this.$('sessionTabs')?.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`)?.focus();
|
||||
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;
|
||||
// Quoted: a group may be NAMED "New group" or "ungrouped", which unquoted
|
||||
// would read (and, case-insensitively, translate) exactly like the
|
||||
// fixed "Move to new group" / "Move to Ungrouped" entries next to it.
|
||||
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) };
|
||||
// `fromBlur`: focus already moved somewhere the user chose (the terminal,
|
||||
// another control). Pulling it back to the header from inside the blur
|
||||
// handler wins over that move, so a blur commit never asks for refocus.
|
||||
const settle = (commit, { fromBlur = false } = {}) => {
|
||||
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);
|
||||
const focusIdentity = fromBlur ? null : `group:${groupId}`;
|
||||
if (commit && current && name && name !== current.name) {
|
||||
if (this.editTabLayout({ type: 'renameGroup', groupId, name }, focusIdentity)) return;
|
||||
}
|
||||
if (focusIdentity) {
|
||||
this._tabFocusIdentity = focusIdentity;
|
||||
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, { fromBlur: 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) {
|
||||
// A press whose release never reached us (let go outside the rail, or
|
||||
// outside the window) must not survive into this one: a stale pending
|
||||
// press turned into a phantom drag on the next hover, and a replaced drag
|
||||
// left its Escape listener behind for good.
|
||||
if (this._tabLayoutDrag) this._cancelTabLayoutPointerDrag(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;
|
||||
const drag = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, source, origin: header || row, active: false, target: null };
|
||||
// Capture is only taken once the press becomes a drag, so until then the
|
||||
// release can land outside the rail: hear it on window.
|
||||
drag.windowUp = (upEvent) => this._finishTabLayoutPointerDrag(upEvent, container);
|
||||
drag.windowCancel = () => this._cancelTabLayoutPointerDrag(container);
|
||||
window.addEventListener('pointerup', drag.windowUp, true);
|
||||
window.addEventListener('pointercancel', drag.windowCancel, true);
|
||||
this._tabLayoutDrag = drag;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// The primary button is up, so the release went somewhere we never heard.
|
||||
if ((e.buttons & 1) === 0) {
|
||||
this._cancelTabLayoutPointerDrag(container);
|
||||
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 {}
|
||||
if (this._tabLayoutDragKeydown) document.removeEventListener('keydown', this._tabLayoutDragKeydown, true);
|
||||
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;
|
||||
if (drag.windowUp) window.removeEventListener('pointerup', drag.windowUp, true);
|
||||
if (drag.windowCancel) window.removeEventListener('pointercancel', drag.windowCancel, true);
|
||||
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({ owner: this._tabLayoutOwnerKey(), baseVersion: editor.baseVersion(), savedAt: Date.now(), 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;
|
||||
}
|
||||
|
||||
/** Whose layout this page edits, as the server keys it (`@single` without multi-user). */
|
||||
_tabLayoutOwnerKey() {
|
||||
const me = window.__codemanUser;
|
||||
if (!me) return null;
|
||||
return me.multiUser ? me.username : '@single';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay the previous page's unsaved edits, but only that page's: the copy is
|
||||
* ignored when it belongs to another owner (a different login in this tab),
|
||||
* is older than a reload could explain, or names a layout newer than the one
|
||||
* just read (a different server behind the same origin).
|
||||
*/
|
||||
_restorePendingTabLayoutEdits() {
|
||||
let saved;
|
||||
try {
|
||||
const raw = sessionStorage.getItem('codeman:tab-layout-pending');
|
||||
if (!raw) return false;
|
||||
saved = JSON.parse(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const owner = this._tabLayoutOwnerKey();
|
||||
if (owner === null) {
|
||||
// Who we are is not known yet (/api/me still loading): decide once it is.
|
||||
if (!this._tabLayoutRestoreWaiting) {
|
||||
this._tabLayoutRestoreWaiting = true;
|
||||
document.addEventListener(
|
||||
'codeman:me',
|
||||
() => {
|
||||
this._tabLayoutRestoreWaiting = false;
|
||||
this._restorePendingTabLayoutEdits();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem('codeman:tab-layout-pending');
|
||||
} catch {}
|
||||
const operations = saved?.operations;
|
||||
if (!Array.isArray(operations) || !operations.length || !this.tabLayout || !window.CodemanTabLayout) return false;
|
||||
if (saved.owner !== owner) return false;
|
||||
const age = Date.now() - saved.savedAt;
|
||||
if (!Number.isFinite(age) || age < 0 || age > TAB_LAYOUT_PENDING_MAX_AGE_MS) return false;
|
||||
if (!Number.isSafeInteger(saved.baseVersion) || saved.baseVersion > this.tabLayout.version) return false;
|
||||
return this._ensureTabLayoutEditor().restore(operations);
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -6457,9 +7138,9 @@ class CodemanApp {
|
||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||
// order this list is no longer showing.
|
||||
// The grouped rail is read-only for now: a flat-order drag cannot express
|
||||
// "move into that group", and the server would re-rank it within its old
|
||||
// group anyway. Grouped editing comes with its own drag model.
|
||||
// The grouped rail opts out too: a flat-order drag cannot express "move
|
||||
// into that group", and the server would re-rank it within its old group
|
||||
// anyway. It has its own pointer drag (_bindTabLayoutPointerDrag).
|
||||
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
|
||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||
return;
|
||||
@@ -6557,7 +7238,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,24 @@
|
||||
'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.': '标签分组在别处持续更改;你的编辑未保存。',
|
||||
'Your tab group edit was not saved.': '你的标签分组编辑未保存。',
|
||||
'Open session manager': '打开会话管理器',
|
||||
Attachments: '附件',
|
||||
'Open attachment history': '打开附件历史',
|
||||
@@ -934,6 +952,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,86 @@ 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;
|
||||
}
|
||||
|
||||
/* No hover on a touch tablet, and a long press there is not a contextmenu
|
||||
event: the glyph is the only way into the group menu, so keep it shown. */
|
||||
@media (hover: none) {
|
||||
html[data-tab-orientation='vertical'] .tab-rail .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,370 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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)));
|
||||
// No anchor means "at the end", and an operation with no index keeps
|
||||
// meaning that when it is replayed onto a layout that has changed since.
|
||||
if (!anchor) return { groupId };
|
||||
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
|
||||
if (at < 0) return { groupId };
|
||||
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,
|
||||
...(destination.index === undefined ? {} : { 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;
|
||||
let rereadFor400 = 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 && !rereadFor400) {
|
||||
// Maybe our base was stale in a way the server reports as invalid:
|
||||
// re-read once. A 400 that survives that is a refusal, not a race.
|
||||
rereadFor400 = true;
|
||||
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 +731,12 @@
|
||||
hiddenGroupAlerts,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
applyOperation,
|
||||
moveDestination,
|
||||
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
|
||||
dropOperation,
|
||||
contentKey,
|
||||
createEditCoordinator,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
|
||||
@@ -298,7 +298,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
closeTabRailActionMenu(options = {}) {
|
||||
const menu = document.querySelector('.tab-rail-action-menu');
|
||||
// The group menu borrows this class for its look but has its own owner
|
||||
// (closeTabGroupMenu); removing its DOM here would strand its listeners.
|
||||
const menu = document.querySelector('.tab-rail-action-menu:not(.tab-layout-group-action-menu)');
|
||||
const trigger = this._tabRailActionMenuTrigger;
|
||||
menu?.remove();
|
||||
if (this._tabRailActionMenuOutside) {
|
||||
@@ -325,6 +327,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const actions = [
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
|
||||
@@ -91,6 +91,18 @@ describe('custom display name and browser localization', () => {
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => {
|
||||
const dom = makeDom('');
|
||||
const api = dom.window.CodemanI18n;
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const labels = ['Move to "New group"', 'Move to new group', 'Move to "ungrouped"', 'Move to Ungrouped'].map((label) =>
|
||||
api.t(label)
|
||||
);
|
||||
expect(labels).toEqual(['移到“New group”', '移到新分组', '移到“ungrouped”', '移到未分组']);
|
||||
expect(new Set(labels).size).toBe(4);
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it('renders hostile-looking names as text rather than HTML', () => {
|
||||
const dom = makeDom('<span class="logo">Codeman</span>');
|
||||
const api = dom.window.CodemanI18n;
|
||||
|
||||
@@ -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,364 @@
|
||||
/**
|
||||
* @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>
|
||||
<textarea id="term" aria-label="stand-in for the terminal" style="position:absolute;left:700px;top:300px"></textarea>
|
||||
</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;
|
||||
// Escapes that reach the stand-in terminal (xterm listens on its textarea).
|
||||
w.__termEscapes = 0;
|
||||
document.getElementById('term')!.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') w.__termEscapes++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
w.__termEscapes = 0;
|
||||
}, 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('a press released outside the rail leaves nothing behind, and Escape still reaches the terminal', async () => {
|
||||
const a = await box('.session-tab[data-id="one"]');
|
||||
// Press on a row and flick out of the rail in ONE move, releasing out there:
|
||||
// the rail never sees the move or the release.
|
||||
await page.mouse.move(a.x + 20, a.y + a.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(760, 600);
|
||||
await page.mouse.up();
|
||||
// The release was heard on window: the press is gone before any hover.
|
||||
expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull();
|
||||
// Hovering back with no button down must not turn into a phantom drag.
|
||||
const b = await box('[data-tab-group-header="gy"]');
|
||||
await page.mouse.move(b.x + 30, b.y + b.height / 2, { steps: 6 });
|
||||
expect(await page.locator('.tab-layout-dragging, .tab-layout-drop-into, .tab-layout-drag-active').count()).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).__app._tabLayoutDrag)).toBeNull();
|
||||
|
||||
// A real drag after that, cancelled with Escape, then one more completed.
|
||||
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 });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.up();
|
||||
await drag('.session-tab[data-id="one"]', '[data-tab-group-header="gy"]');
|
||||
await settled();
|
||||
expect(puts).toHaveLength(1);
|
||||
|
||||
// No drag listener is left in document capture swallowing Escape.
|
||||
await page.locator('#term').focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(2);
|
||||
expect(await page.evaluate(() => (window as any).__app._tabLayoutDragKeydown)).toBeNull();
|
||||
});
|
||||
|
||||
it('each guard holds on its own: buttons-up move, a second press, no stacked Escape listener', async () => {
|
||||
// Synthetic pointer events reach the cases a real mouse cannot isolate (a
|
||||
// release outside the WINDOW never reaches any listener of ours).
|
||||
const result = await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const rail = document.getElementById('sessionTabs')!;
|
||||
const at = (el: Element) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { clientX: r.left + 20, clientY: r.top + r.height / 2 };
|
||||
};
|
||||
const fire = (target: Element, type: string, init: PointerEventInit) =>
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, pointerType: 'mouse', ...init })
|
||||
);
|
||||
const one = rail.querySelector('.session-tab[data-id="one"] .tab-name')!;
|
||||
const three = rail.querySelector('.session-tab[data-id="three"] .tab-name')!;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
// 1. A pending press, then a move with no button down: cancelled, no drag.
|
||||
fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) });
|
||||
fire(three, 'pointermove', { buttons: 0, ...at(three) });
|
||||
out.afterButtonsUp = w.__app._tabLayoutDrag;
|
||||
out.dragClass = rail.querySelectorAll('.tab-layout-dragging').length;
|
||||
|
||||
// 2. An active drag whose release never arrived, then a new press.
|
||||
fire(one, 'pointerdown', { button: 0, buttons: 1, ...at(one) });
|
||||
fire(three, 'pointermove', { buttons: 1, ...at(three) });
|
||||
out.firstActive = w.__app._tabLayoutDrag?.active === true;
|
||||
const firstListener = w.__app._tabLayoutDragKeydown;
|
||||
fire(three, 'pointerdown', { button: 0, buttons: 1, ...at(three) });
|
||||
out.staleOrigin = rail.querySelectorAll('.tab-layout-dragging').length;
|
||||
out.firstListenerKept = w.__app._tabLayoutDragKeydown === firstListener;
|
||||
fire(one, 'pointermove', { buttons: 1, ...at(one) });
|
||||
fire(one, 'pointerup', { button: 0, buttons: 0, ...at(one) });
|
||||
out.leftover = w.__app._tabLayoutDragKeydown;
|
||||
return out;
|
||||
});
|
||||
expect(result.afterButtonsUp).toBeNull();
|
||||
expect(result.dragClass).toBe(0);
|
||||
expect(result.firstActive).toBe(true);
|
||||
expect(result.staleOrigin).toBe(0);
|
||||
expect(result.firstListenerKept).toBe(false);
|
||||
expect(result.leftover).toBeNull();
|
||||
await page.locator('#term').focus();
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(() => (window as any).__termEscapes)).toBe(1);
|
||||
await settled();
|
||||
});
|
||||
|
||||
it('committing a group rename by clicking elsewhere leaves focus where the click put it', async () => {
|
||||
await page.evaluate(() => (window as any).__app.startTabGroupRename('gy'));
|
||||
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('Elsewhere');
|
||||
await page.locator('#term').click();
|
||||
await settled();
|
||||
expect(puts.at(-1).layout.groups[1].name).toBe('Elsewhere');
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe('term');
|
||||
// So Enter goes to the terminal, not to the header (which would collapse it).
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await page.evaluate(() => (window as any).__app.collapsedTabGroupIds.size)).toBe(0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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