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
+23
View File
@@ -68,6 +68,23 @@
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Group actions': '分组操作',
'Group name': '分组名称',
'Web tab actions': '网页标签操作',
'Web tab settings': '网页标签设置',
'New group': '新建分组',
'Rename group': '重命名分组',
'Move group up': '上移分组',
'Move group down': '下移分组',
'Delete group': '删除分组',
'Move up': '上移',
'Move down': '下移',
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
@@ -934,6 +951,12 @@
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
// Group names are user text: they pass through untranslated.
[/^Move to (.+)$/, (_m, group) => `移到 ${group}`],
[
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
],
];
for (const [pattern, replacement] of patterns) {
const match = source.match(pattern);