feat(tabs): edit groups in the vertical rail

The grouped vertical rail can now be edited from the browser: groups are
created, renamed, reordered and deleted, and tabs are moved between them, by
menu, keyboard or pointer drag. Every edit is saved through the existing
PUT /api/tab-layout; there are no server changes.

Saving (tab-layout-browser.js, pure):
- Edits are named operations (createGroup, renameGroup, deleteGroup,
  reorderGroup, moveRef) applied to the rail at once, mirroring the server
  model: a moved session takes the sessions that still follow it, and a
  hand-moved child is marked placement 'manual'. normalizeLayout now keeps
  placement and updatedAt, since whole layouts are written back.
- createEditCoordinator keeps ONE PUT {baseVersion, layout} in flight. Edits
  made in the same turn share a write; edits made while one is in flight go
  out on the version it returns. A 409 replays the operations onto the
  layout the server returned and retries (bounded); an operation that no
  longer applies is dropped and reported. A 400 re-reads first; any other
  failure reports and re-reads.
- dropOperation maps a finished drag to one operation, or null for a drop
  that changes nothing.

Wiring (app.js, tab-rail-resize.js):
- The session row menu gains Move up/down, Move to <group>, Move to
  Ungrouped and Move to new group in the vertical rail. Before the first
  group exists it offers only "Move to new group", which is how a flat rail
  becomes grouped; the header strip's menu is unchanged.
- A group header opens its menu with Shift+F10 / ContextMenu, right-click or
  a hover glyph (a non-focusable aria-hidden span, so the treeitem still
  holds no interactive child): Rename, New group, Move group up/down,
  Delete. F2 renames inline. A web tab row's Shift+F10 opens its settings
  plus the same moves.
- The menu closes on Escape (consumed before the global Escape handler, focus
  back to its row or header), a pointer outside, Tab, focus leaving it, a
  resize, a second open and any full re-render.
- Inline group rename shares the session rename's ownership handle, so only
  the current editor releases the render guard. Enter or blur commits,
  Escape cancels, IME composition keys are left to the IME, and the label
  becomes a flex slot so the editor gets the full width while typing.
- Pointer drag (mouse and pen) in the grouped rail only: rows before/after a
  row or into a group, a header drag reorders groups. Escape cancels; the
  click that ends a drag neither selects nor toggles. The flat rail and the
  header strip keep their HTML5 drag untouched.
- A tab:layoutChanged read is deferred while a write is in flight and run
  once it settles; a read otherwise rebases unsaved edits. On pagehide,
  unconfirmed edits go out in a keepalive PUT and into sessionStorage, and
  replay after reload (a no-op when the keepalive landed).
- New strings have zh-CN entries; group names reach the DOM only as text.

Unchanged: the flat rail's markup when no group exists, the tree semantics
and single roving tab stop, sessionOrder and Alt+N.

Tests: test/tab-layout-editing.test.ts (operations, coordinator, drop
mapping, menus, rename, dismissal, SSE deferral, reload recovery, flat-rail
identity) and test/tab-layout-editing.browser.test.ts (real pointer drags,
editor paint, menu Escape), listed in BROWSER_TEST_GLOBS.
This commit is contained in:
Aamer Akhter
2026-10-04 20:10:32 -04:00
parent ffaa5ee80c
commit 97cb5b5799
12 changed files with 2239 additions and 20 deletions
+390 -6
View File
@@ -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)">&#x22EF;</span></div>' +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
@@ -340,6 +360,364 @@
};
}
// ─── Editing ────────────────────────────────────────────────────────────
//
// The browser edits through NAMED operations, not by diffing arrays: a write
// that loses a version race (409) is rebased by replaying the same operations
// on the layout the server returned, so a concurrent edit elsewhere survives.
// The server stays the authority: it re-validates and normalizes every PUT.
function editError(message) {
throw new Error(`Tab layout edit failed: ${message}`);
}
const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length);
function groupName(value) {
const name = typeof value === 'string' ? value.trim() : '';
if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`);
return name;
}
function refLocations(layout) {
return [
...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))),
...layout.ungrouped.map((ref) => ({ groupId: null, ref })),
];
}
function containerRefs(layout, groupId) {
if (groupId === null) return layout.ungrouped;
const group = layout.groups.find((candidate) => candidate.id === groupId);
if (!group) editError('unknown group');
return group.refs;
}
/**
* The rows that move together with `ref`: the session plus every descendant
* that still follows its parent (non-manual, parent stored). Mirrors the
* server's moveRef block so the optimistic rail matches what it will store.
* `parents` maps a session id to its parent session id.
*/
function lineageBlock(layout, ref, parents) {
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
const children = new Map();
for (const [childId, parentId] of Object.entries(parents || {})) {
const child = stored.get(`session:${childId}`);
if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(childId);
}
const keys = new Set();
const visit = (key) => {
if (keys.has(key)) return;
keys.add(key);
if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`);
};
visit(refKey(ref));
return keys;
}
/**
* Where a moved row lands, as the server's `index` (counted AFTER the moved
* block is taken out): before or after `anchor` in that container, or at its
* end when there is no anchor.
*/
function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) {
const layout = normalizeLayout(layoutInput);
const block = lineageBlock(layout, ref, parents);
const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate)));
if (!anchor) return { groupId, index: remaining.length };
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
if (at < 0) return { groupId, index: remaining.length };
return { groupId, index: placement === 'after' ? at + 1 : at };
}
/**
* Map a finished drag to ONE operation (or null for a drop that changes
* nothing). Pure, so the drop -> PUT mapping is testable without a pointer.
*
* source: { type: 'ref', ref } | { type: 'group', groupId }
* target: { type: 'ref', ref, groupId, placement: 'before' | 'after' }
* | { type: 'group', groupId } (a named group's header or empty body)
* | { type: 'ungrouped' }
*
* A group dropped on another group (or any row in it) takes that group's slot;
* dropped on the Ungrouped section it goes last. A row dropped on a row lands
* before/after it, on a header it is appended to that group.
*/
function dropOperation(layoutInput, source, target, parents) {
const layout = normalizeLayout(layoutInput);
if (!source || !target) return null;
if (source.type === 'group') {
const from = layout.groups.findIndex((group) => group.id === source.groupId);
if (from < 0) return null;
const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null);
const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId);
if (to < 0 || to === from) return null;
return { type: 'reorderGroup', groupId: source.groupId, index: to };
}
if (source.type !== 'ref' || !validRef(source.ref)) return null;
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref));
if (!location) return null;
let groupId;
let anchor = null;
let placement = 'before';
if (target.type === 'ref' && validRef(target.ref)) {
// Onto itself or onto a row that moves with it: nowhere to go.
if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null;
groupId = target.groupId ?? null;
anchor = target.ref;
placement = target.placement === 'after' ? 'after' : 'before';
} else if (target.type === 'group') {
groupId = target.groupId ?? null;
if (groupId === location.groupId) return null;
} else if (target.type === 'ungrouped') {
groupId = null;
if (location.groupId === null) return null;
} else return null;
if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null;
const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents);
const operation = {
type: 'moveRef',
ref: { kind: source.ref.kind, id: source.ref.id },
groupId: destination.groupId,
index: destination.index,
parents: parents || {},
};
return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation;
}
/**
* Apply one operation to a copy of the layout. Throws when the operation no
* longer makes sense (an unknown group or row); a rebase drops that one
* operation and keeps the rest. Replays are idempotent where it matters for
* recovery: creating a group that already exists is a no-op.
*/
function applyOperation(layoutInput, operation) {
const layout = normalizeLayout(layoutInput);
const op = operation || {};
const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId);
switch (op.type) {
case 'createGroup': {
if (typeof op.id !== 'string' || !op.id) editError('invalid group id');
const name = groupName(op.name);
if (layout.groups.some((group) => group.id === op.id)) return layout;
if (layout.groups.length >= MAX_GROUPS) editError('group limit reached');
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] });
return layout;
}
case 'renameGroup':
if (groupIndex < 0) editError('unknown group');
layout.groups[groupIndex].name = groupName(op.name);
return layout;
case 'deleteGroup': {
// Already gone (deleted elsewhere): nothing left to do.
if (groupIndex < 0) return layout;
const [removed] = layout.groups.splice(groupIndex, 1);
layout.ungrouped.push(...removed.refs);
return layout;
}
case 'reorderGroup': {
if (groupIndex < 0) editError('unknown group');
const [moved] = layout.groups.splice(groupIndex, 1);
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
return layout;
}
case 'moveRef': {
if (!validRef(op.ref)) editError('invalid row');
const targetKey = refKey(op.ref);
if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row');
const destinationId = op.groupId ?? null;
containerRefs(layout, destinationId);
const keys = lineageBlock(layout, op.ref, op.parents);
const block = refLocations(layout)
.filter((item) => keys.has(refKey(item.ref)))
.map((item) => copyRef(item.ref));
// A hand-moved child stops following its parent (server moveRef does the same).
const head = block.find((item) => refKey(item) === targetKey);
if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual';
block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0));
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref)));
layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref)));
const destination = containerRefs(layout, destinationId);
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
return layout;
}
default:
return editError(`unknown operation ${op.type}`);
}
}
/** Layout content without version metadata: equal keys mean "nothing to save". */
function contentKey(layoutInput) {
const layout = normalizeLayout(layoutInput);
return JSON.stringify([layout.groups, layout.ungrouped]);
}
/** Replay operations, dropping (and counting) the ones that no longer apply. */
function replayOperations(base, operations) {
let layout = normalizeLayout(base);
const kept = [];
let dropped = 0;
for (const operation of operations) {
try {
layout = applyOperation(layout, operation);
kept.push(operation);
} catch (_error) {
dropped++;
}
}
return { layout, kept, dropped };
}
/**
* Serialized, optimistic writer for `PUT /api/tab-layout`.
*
* - enqueue() applies an operation at once (the rail repaints optimistically)
* and schedules a flush; operations enqueued in the same turn share a PUT.
* - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are
* sent on top of the version that write returns.
* - A 409 carries the server's current layout: the in-flight operations are
* replayed onto it and re-sent with its version (bounded attempts). A 400
* (a row vanished between read and write) re-reads and rebases the same way.
* - Anything else, or attempts exhausted, drops the batch and reports it; the
* caller re-reads so the rail shows the server's truth.
*
* options: { initialLayout, put({ baseVersion, layout }) -> { ok, status,
* layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message),
* onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? }
*/
function createEditCoordinator(options) {
let authoritative = normalizeLayout(options.initialLayout);
let optimistic = authoritative;
let pending = [];
let inFlight = [];
let writing = false;
let timer = null;
let disposed = false;
const schedule = options.schedule || ((fn) => setTimeout(fn, 0));
const cancel = options.cancel || ((handle) => clearTimeout(handle));
const maxAttempts = options.maxAttempts || 3;
const report = (message) => options.reportError?.(message);
const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta);
const queue = () => {
if (timer === null) timer = schedule(flush);
};
async function flush() {
timer = null;
if (disposed || writing || pending.length === 0) return;
writing = true;
inFlight = pending;
pending = [];
let failed = false;
let reportedDrop = false;
try {
for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) {
const desired = replayOperations(authoritative, inFlight);
inFlight = desired.kept;
if (desired.dropped && !reportedDrop) {
reportedDrop = true;
report('Tab groups changed elsewhere; part of your edit no longer applies.');
}
// Nothing left to change (dropped, or already true on the server).
if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) {
inFlight = [];
break;
}
const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout });
if (disposed) return;
if (response?.ok && response.layout) {
authoritative = normalizeLayout(response.layout);
inFlight = [];
} else if (response?.status === 409 && response.layout) {
authoritative = normalizeLayout(response.layout);
} else if (response?.status === 400 && options.fetchLayout) {
authoritative = normalizeLayout(await options.fetchLayout());
if (disposed) return;
} else {
throw new Error('Tab layout save failed');
}
}
if (inFlight.length) {
failed = true;
report('Tab groups kept changing elsewhere; your edit was not saved.');
}
} catch (_error) {
failed = true;
report('Could not save tab groups.');
} finally {
inFlight = [];
writing = false;
if (!disposed) {
const rebased = replayOperations(authoritative, pending);
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true });
if (failed) options.onFailure?.();
if (pending.length) queue();
else options.onSettled?.();
}
}
}
return {
/** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */
enqueue(operation) {
optimistic = applyOperation(optimistic, operation);
pending.push(operation);
publish({ optimistic: true });
queue();
return normalizeLayout(optimistic);
},
/**
* Re-apply operations recovered after a reload. Returns false (and queues
* nothing) when the layout already reflects them, e.g. the keepalive save
* landed before the page went away.
*/
restore(operations) {
const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []);
if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false;
optimistic = replayed.layout;
pending.push(...replayed.kept);
publish({ optimistic: true });
queue();
return true;
},
/**
* Adopt a layout read from the server (SSE reload). Pending operations are
* rebased onto it. Refused while a write is in flight (its result decides)
* and for a layout older than the one already held.
*/
adoptExternal(layout) {
if (disposed || writing) return false;
const next = normalizeLayout(layout);
if (next.version < authoritative.version) return false;
authoritative = next;
const rebased = replayOperations(next, pending);
if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.');
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true, external: true });
return true;
},
flush,
isWriting: () => writing,
hasPending: () => writing || pending.length > 0,
/** Every operation not yet confirmed by the server, oldest first. */
pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])),
baseVersion: () => authoritative.version,
getLayout: () => normalizeLayout(optimistic),
dispose() {
disposed = true;
if (timer !== null) cancel(timer);
timer = null;
pending = [];
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
@@ -347,6 +725,12 @@
hiddenGroupAlerts,
structureKey,
renderProjection,
applyOperation,
moveDestination,
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
dropOperation,
contentKey,
createEditCoordinator,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,