mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
fix(tabs): collapsed-header alerts, quieter layout reads, tree key and touch fixes (#517, #519 review)
Maintainer merge-time fixes for the grouped vertical rail (#517) and its tree semantics (#519), from the two PR reviews. #517 minors - A collapsed group hid rows that need the user with no signal on its header. The header now takes the most urgent alert among the session rows its collapse hides, in the tab alert language (tab-alert-action red ring, tab-alert-idle yellow ring, the existing ::before rules extended to the header). New pure hiddenGroupAlerts() over a per-section `hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render paths, since alerts change without a rebuild. The kept selection draws its own ring and is not counted. - Every layout read rebuilt the whole tab strip, and failed reads retried every 5 s forever. _applyTabLayout() now rebuilds only when the structure key changed. The key drops the layout version (bumped on every session create/close and order PUT) and instead carries group names and the rows each collapse hides, so a version bump that moves nothing costs nothing and a rename still rebuilds. The load coordinator backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries; the next SSE init or tab:layoutChanged tries again, a success resets. - A malformed stored collapse value disabled collapse on that device for good. A parse or shape error now reads as nothing collapsed and is rewritten to []; ok:false stays reserved for a store that throws. - Ctrl+Shift+{ / } still reordered across groups, where the server re-ranks per group, sends no session:orderChanged and leaves this client's sessionOrder and Alt+N targets diverged. The move is now a no-op unless the neighbour is in the active session's own section (_canSwapActiveTabWith, reading the projection's new sectionByRef, which also covers rows a collapse hides). Within a group the swap still works and the server agrees with it; the flat rail and the strip are unchanged. #517 nits - Keyboard group toggle dropping focus: already fixed by #519's focus-by-identity; the Enter toggle test now pins focus on the header. - Header <button> inside role=tablist: moot, #519 made the header a treeitem inside role=tree. - Byte-identity test not comparing against master: skipped in the suite (a test cannot read another revision's files portably). Checked by hand instead: the flat strip and flat rail markup of this branch before and after this commit are identical in all 16 cases (both orientations, manual and activity sort, no layout and zero groups, full and incremental paths). - Doubled blank line in docs/architecture-invariants.md: removed. #519 minors - A tap on a tree header or unselected row dismissed the touch keyboard: the roving tabindex parks those at -1, so the [tabindex] arm of MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now lists [role="treeitem"]. - The tree key handler acted on keys pressed on a focused control inside a row (Enter on the overflow button re-selected and reloaded the active session instead of reopening its menu). It now returns unless the key landed on the treeitem itself. #519 nits - aria-posinset/setsize went stale when the activity-sorted grouped rail re-sorted rows on the incremental path. The position pass is extracted (_applyTabTreePositions) and re-run, with aria-selected and the header alerts, at the end of the incremental branch while the rail is a tree. - An expanded group with no open rows was announced as an expanded parent owning an empty group. A group with no open rows is now a tree leaf: no aria-expanded, no aria-owns, its rows container presentation; Left and Right do nothing on it, and its chevron keys off the section's collapsed class instead of aria-expanded. Tests: tab-layout-browser (malformed storage, backoff with a bounded drain, structure key, hidden alerts, leaf groups, sectionByRef), tab-layout-rail (header alerts on both paths, render-on-change, backoff without rebuilds, malformed storage, Ctrl+Shift section gate, in-row control keys, leaf header keys, posinset after an incremental re-sort, the dismiss selector matching tree items), and three new Chromium tests in tab-activation.browser (Enter on a focused overflow button, the touch keyboard staying up on tree taps, the collapsed header's red ring). Every new test fails on the pre-fix sources. Docs: architecture-invariants owner-tab-layouts and keyboard-dismissal sections, one clause in CLAUDE.md's dismissal rule. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -370,7 +370,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
|
||||
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
|
||||
@@ -298,14 +298,14 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
|
||||
⚠️ **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:
|
||||
|
||||
- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (retried, newest-wins via `createLoadCoordinator`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group.
|
||||
- **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, any storage failure means all-expanded). 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.
|
||||
- **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.
|
||||
- **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, 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.
|
||||
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent.
|
||||
|
||||
@@ -939,7 +939,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions.
|
||||
|
||||
**(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`.
|
||||
**(1)** `_installMobileKeyboardDismiss()`, a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. ⚠️ A roving-tabindex widget defeats that arm: the grouped rail's tree parks every item but one at `tabindex=-1`, and its group headers are not buttons, so the selector lists `[role="treeitem"]` by role (pinned by `test/tab-layout-rail.test.ts` and `test/tab-activation.browser.test.ts`).
|
||||
|
||||
**(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`.
|
||||
|
||||
|
||||
+93
-20
@@ -5581,6 +5581,15 @@ class CodemanApp {
|
||||
subagentBadgeEl.remove();
|
||||
}
|
||||
}
|
||||
// Grouped tree: the loop above can re-sort rows (`style.order`), move the
|
||||
// highlight, and change the alerts a collapsed header stands in for, none
|
||||
// of which rebuilds the rail. Keep what assistive tech and the headers
|
||||
// report in step. The flat list (role=tablist) never takes this branch.
|
||||
if (groupProjection && container.getAttribute('role') === 'tree') {
|
||||
this._syncTabTreeSelection(container);
|
||||
this._applyTabTreePositions(container);
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5859,7 +5868,10 @@ class CodemanApp {
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
this._applyTabListRole(container, !!groupProjection);
|
||||
if (groupProjection) this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
if (groupProjection) {
|
||||
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -6065,21 +6077,7 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
|
||||
// Position within each level: the level-1 run (headers, ungrouped rows, a
|
||||
// collapsed group's kept row) and each group's own rows.
|
||||
const items = this._tabTreeItems(container);
|
||||
const sets = new Map();
|
||||
for (const item of items) {
|
||||
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
|
||||
if (!sets.has(owner)) sets.set(owner, []);
|
||||
sets.get(owner).push(item);
|
||||
}
|
||||
for (const members of sets.values()) {
|
||||
members.forEach((item, index) => {
|
||||
item.setAttribute('aria-setsize', String(members.length));
|
||||
item.setAttribute('aria-posinset', String(index + 1));
|
||||
});
|
||||
}
|
||||
const items = this._applyTabTreePositions(container);
|
||||
|
||||
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
|
||||
// A focused row that a collapse just hid hands focus to its group header.
|
||||
@@ -6094,6 +6092,47 @@ class CodemanApp {
|
||||
if (refocus && document.activeElement !== target) target.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* aria-posinset / aria-setsize within each level, in PAINTED order: the
|
||||
* level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each
|
||||
* group's own rows. Runs after every full render AND after an incremental pass,
|
||||
* because the activity-sorted rail re-sorts rows in place. Returns the items.
|
||||
*/
|
||||
_applyTabTreePositions(container) {
|
||||
const items = this._tabTreeItems(container);
|
||||
const sets = new Map();
|
||||
for (const item of items) {
|
||||
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
|
||||
if (!sets.has(owner)) sets.set(owner, []);
|
||||
sets.get(owner).push(item);
|
||||
}
|
||||
for (const members of sets.values()) {
|
||||
members.forEach((item, index) => {
|
||||
const setsize = String(members.length);
|
||||
const posinset = String(index + 1);
|
||||
if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize);
|
||||
if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed group hides its rows, including ones that need the user. Its
|
||||
* header takes the most urgent hidden alert in the tab alert language
|
||||
* (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt
|
||||
* behind a collapse is never invisible. Patched in place on both render paths:
|
||||
* alerts change without a rebuild.
|
||||
*/
|
||||
_syncTabGroupHeaderAlerts(container, projection) {
|
||||
const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {};
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
|
||||
const alert = alerts[header.dataset.tabGroupHeader];
|
||||
header.classList.toggle('tab-alert-action', alert === 'action');
|
||||
header.classList.toggle('tab-alert-idle', alert === 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep aria-selected on the grouped tree in step with the .active class. */
|
||||
_syncTabTreeSelection(container) {
|
||||
if (container?.getAttribute('role') !== 'tree') return;
|
||||
@@ -6112,10 +6151,17 @@ class CodemanApp {
|
||||
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
const items = this._tabTreeItems(container);
|
||||
const current = e.target?.closest?.('[role="treeitem"]');
|
||||
// A key pressed on a control INSIDE a row (its close or overflow button,
|
||||
// focused by a click or handed focus back by the action menu) belongs to
|
||||
// that control: Enter there must reopen the menu, not re-select the row.
|
||||
// Same contract as the flat list, which acts only on a focused row.
|
||||
if (!current || current !== e.target) return;
|
||||
const index = items.indexOf(current);
|
||||
if (index < 0) return;
|
||||
const groupId = current.dataset.tabGroupHeader || null;
|
||||
const expanded = current.getAttribute('aria-expanded') === 'true';
|
||||
// A group with no open rows is a leaf (no aria-expanded): nothing to open.
|
||||
const expandable = current.hasAttribute('aria-expanded');
|
||||
const focusAt = (next) => {
|
||||
if (!next) return;
|
||||
this._setTabTreeStop(container, next);
|
||||
@@ -6141,7 +6187,7 @@ class CodemanApp {
|
||||
focusAt(items[items.length - 1]);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (!groupId) return;
|
||||
if (!groupId || !expandable) return;
|
||||
if (!expanded) toggle(false);
|
||||
else {
|
||||
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
|
||||
@@ -6151,7 +6197,7 @@ class CodemanApp {
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (groupId) {
|
||||
if (!expanded) return;
|
||||
if (!expandable || !expanded) return;
|
||||
toggle(true);
|
||||
} else {
|
||||
const group = current.closest('[role="group"]');
|
||||
@@ -6287,7 +6333,13 @@ class CodemanApp {
|
||||
},
|
||||
applyLayout: (layout) => this._applyTabLayout(layout),
|
||||
applyFallback: () => this._applyTabLayout(null),
|
||||
scheduleRetry: (retry) => setTimeout(retry, 5000),
|
||||
// 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or
|
||||
// tab:layoutChanged asks again: an unreachable server must not cost a
|
||||
// fetch every 5 s for as long as the page stays open.
|
||||
retryDelayMs: 5000,
|
||||
maxRetryDelayMs: 60000,
|
||||
maxRetries: 4,
|
||||
scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs),
|
||||
cancelRetry: (timer) => clearTimeout(timer),
|
||||
});
|
||||
return this._tabLayoutCoordinator;
|
||||
@@ -6327,7 +6379,11 @@ class CodemanApp {
|
||||
: { ids: [], ok: !next };
|
||||
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
|
||||
this.collapsedTabGroupIds = new Set(collapsed.ids);
|
||||
this._fullRenderSessionTabs();
|
||||
// The server announces a layout change on every session create/close, web
|
||||
// tab create/delete and order PUT, and most of those move nothing on this
|
||||
// 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();
|
||||
}
|
||||
|
||||
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
|
||||
@@ -6495,10 +6551,26 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a
|
||||
* neighbour in its OWN section. Across a group boundary the server re-ranks
|
||||
* 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.
|
||||
*/
|
||||
_canSwapActiveTabWith(neighbourId) {
|
||||
const projection = this._projectTabGroups();
|
||||
if (!projection) return true;
|
||||
const sectionOf = (id) => projection.sectionByRef[`session:${id}`];
|
||||
return sectionOf(this.activeSessionId) === sectionOf(neighbourId);
|
||||
}
|
||||
|
||||
moveActiveTabLeft() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx <= 0) return;
|
||||
if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) return;
|
||||
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -6508,6 +6580,7 @@ class CodemanApp {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
|
||||
if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return;
|
||||
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
|
||||
@@ -677,6 +677,8 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
|
||||
/* Positioned so a collapsed header can carry the tab alert ring (::before). */
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -728,7 +730,9 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-v
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tab-layout-group-toggle[aria-expanded='false'] .tab-layout-group-chevron::before {
|
||||
/* Keyed on the section, not aria-expanded: a group with no open rows is a tree
|
||||
leaf with no aria-expanded at all, and its chevron must still show its state. */
|
||||
.tab-layout-group--collapsed > .tab-layout-group-toggle .tab-layout-group-chevron::before {
|
||||
content: '\25B8';
|
||||
}
|
||||
|
||||
@@ -1860,7 +1864,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
The overlay paints above the tab's inline content (positioned vs flow), which
|
||||
is fine at these alphas and is exactly what keeps it visible over the active
|
||||
tab's opaque-ish background. */
|
||||
.session-tabs .session-tab.tab-alert-action::before {
|
||||
.session-tabs .session-tab.tab-alert-action::before,
|
||||
.session-tabs .tab-layout-group-header.tab-alert-action::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
@@ -1885,7 +1890,8 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
|
||||
.session-tabs .session-tab.tab-alert-idle::before {
|
||||
.session-tabs .session-tab.tab-alert-idle::before,
|
||||
.session-tabs .tab-layout-group-header.tab-alert-idle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
|
||||
* 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 bounded retry.
|
||||
* read degrades to the flat rail with a capped, backed-off retry.
|
||||
*
|
||||
* The server stays the only authority for layout content. Collapse is a
|
||||
* per-device view preference and lives in localStorage only.
|
||||
@@ -61,18 +61,34 @@
|
||||
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
|
||||
}
|
||||
|
||||
/** Stored collapse ids, or null when the stored value is not a JSON array. */
|
||||
function parseCollapsedIds(raw) {
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? stableIds(parsed) : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
|
||||
* or write threw), and the caller then keeps every group expanded. A malformed
|
||||
* VALUE is not a store failure: it reads as "nothing collapsed" and is
|
||||
* rewritten, or a shape left behind by another build (a rollback) would leave
|
||||
* collapse disabled on this device for good.
|
||||
*/
|
||||
function loadCollapsedGroupIds(storage, validGroupIds) {
|
||||
try {
|
||||
const raw = storage.getItem(COLLAPSED_STORAGE_KEY);
|
||||
const parsed = raw === null ? [] : JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) throw new Error('Invalid collapsed tab groups');
|
||||
const loaded = stableIds(parsed);
|
||||
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
|
||||
const loaded = parsed || [];
|
||||
if (validGroupIds === undefined) return { ids: loaded, ok: true };
|
||||
// Garbage-collect ids of groups that no longer exist, so a deleted group's
|
||||
// id cannot silently collapse a future group that reuses it.
|
||||
const valid = new Set(stableIds(validGroupIds));
|
||||
const kept = loaded.filter((id) => valid.has(id));
|
||||
if (kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
|
||||
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
|
||||
return { ids: kept, ok: true };
|
||||
} catch (_error) {
|
||||
return { ids: [], ok: false };
|
||||
@@ -102,8 +118,11 @@
|
||||
* tab, else the active session), so selecting a hidden session by keyboard,
|
||||
* palette or Alt+N never leaves the user with no visible selection.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef }} null when the
|
||||
* layout has no groups: the caller renders the flat rail unchanged.
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
|
||||
* null when the layout has no groups: the caller renders the flat rail
|
||||
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
|
||||
* collapse hides (`hidden`); `sectionByRef` maps every placed row
|
||||
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
|
||||
*/
|
||||
function project(layoutInput, options = {}) {
|
||||
if (!layoutInput) return null;
|
||||
@@ -123,31 +142,35 @@
|
||||
const placed = new Set();
|
||||
const visibleRefs = [];
|
||||
const hiddenTabGroupByRef = {};
|
||||
const sectionByRef = {};
|
||||
const sections = [];
|
||||
|
||||
const place = (refs, sectionId, isCollapsed) => {
|
||||
const shown = [];
|
||||
const hidden = [];
|
||||
let count = 0;
|
||||
for (const ref of refs) {
|
||||
const key = refKey(ref);
|
||||
if (placed.has(key) || !renderable(ref)) continue;
|
||||
placed.add(key);
|
||||
sectionByRef[key] = sectionId;
|
||||
count++;
|
||||
const copy = { kind: ref.kind, id: ref.id };
|
||||
if (isCollapsed && key !== highlighted) {
|
||||
hiddenTabGroupByRef[key] = sectionId;
|
||||
hidden.push(copy);
|
||||
continue;
|
||||
}
|
||||
const copy = { kind: ref.kind, id: ref.id };
|
||||
shown.push(copy);
|
||||
visibleRefs.push(copy);
|
||||
}
|
||||
return { shown, count };
|
||||
return { shown, hidden, count };
|
||||
};
|
||||
|
||||
for (const group of layout.groups) {
|
||||
const isCollapsed = collapsed.has(group.id);
|
||||
const { shown, count } = place(group.refs, group.id, isCollapsed);
|
||||
sections.push({ id: group.id, name: group.name, refs: shown, count, collapsed: isCollapsed });
|
||||
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
|
||||
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
|
||||
}
|
||||
const omissions = [
|
||||
...liveSessionIds.map((id) => ({ kind: 'session', id })),
|
||||
@@ -155,22 +178,64 @@
|
||||
];
|
||||
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
|
||||
if (ungrouped.count > 0) {
|
||||
sections.push({ id: null, name: '', refs: ungrouped.shown, count: ungrouped.count, collapsed: false });
|
||||
sections.push({
|
||||
id: null,
|
||||
name: '',
|
||||
refs: ungrouped.shown,
|
||||
hidden: [],
|
||||
count: ungrouped.count,
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef };
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
|
||||
}
|
||||
|
||||
const ALERT_RANK = { action: 2, idle: 1 };
|
||||
|
||||
/**
|
||||
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
|
||||
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
|
||||
* selection, any expanded group) draws its own alert, so it is not counted
|
||||
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
|
||||
*/
|
||||
function hiddenGroupAlerts(projection, alertOf) {
|
||||
const result = {};
|
||||
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
|
||||
for (const section of sections) {
|
||||
if (section.id === null || !Array.isArray(section.hidden)) continue;
|
||||
let best = null;
|
||||
for (const ref of section.hidden) {
|
||||
if (ref.kind !== 'session') continue;
|
||||
const alert = alertOf(ref.id);
|
||||
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
|
||||
}
|
||||
if (best) result[section.id] = best;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
|
||||
* where), as opposed to a row's own status/name/badges. The incremental render
|
||||
* path only patches rows in place, so a change here forces a full rebuild.
|
||||
* where, the headers' names, what a collapse hides), as opposed to a row's own
|
||||
* status/name/badges. The incremental render path only patches rows in place,
|
||||
* so a change here forces a full rebuild.
|
||||
*
|
||||
* Deliberately NOT the layout version: the server bumps it on every session
|
||||
* create/close and order PUT, and a bump that moves nothing visible must not
|
||||
* cost every client a full tab-strip rebuild. `layout` is accepted for
|
||||
* signature stability only.
|
||||
*/
|
||||
function structureKey(layout, projection, collapsedGroupIds) {
|
||||
function structureKey(_layout, projection, collapsedGroupIds) {
|
||||
if (!projection) return null;
|
||||
return JSON.stringify({
|
||||
version: layout && Number.isSafeInteger(layout.version) ? layout.version : null,
|
||||
collapsed: stableIds(collapsedGroupIds).sort(),
|
||||
sections: projection.sections.map((section) => [section.id, section.count, section.refs.map(refKey)]),
|
||||
sections: projection.sections.map((section) => [
|
||||
section.id,
|
||||
section.name,
|
||||
section.count,
|
||||
section.refs.map(refKey),
|
||||
(section.hidden || []).map(refKey),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,6 +251,8 @@
|
||||
* (the rows sit below the header visually, not inside it).
|
||||
* - a COLLAPSED group owns nothing: the one row it still shows (the
|
||||
* selection) is a level-1 sibling, never the child of a closed node.
|
||||
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
|
||||
* so it is not announced as an expanded parent of an empty group.
|
||||
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
|
||||
* divider only, hidden from assistive tech, and its rows are not a group.
|
||||
*/
|
||||
@@ -204,10 +271,12 @@
|
||||
const id = escapeHtml(section.id);
|
||||
const refsId = `tab-layout-group-refs-${index}`;
|
||||
const nameId = `tab-layout-group-name-${index}`;
|
||||
const expanded = !section.collapsed;
|
||||
const leaf = section.count === 0;
|
||||
const expanded = !section.collapsed && !leaf;
|
||||
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}" aria-expanded="${expanded ? 'true' : 'false'}"${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)">` +
|
||||
'<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>` +
|
||||
@@ -221,11 +290,21 @@
|
||||
* Newest-wins layout loading. A response that was overtaken by a later load is
|
||||
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
|
||||
* retry, replacing any retry already pending.
|
||||
*
|
||||
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
|
||||
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
|
||||
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
|
||||
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
|
||||
* any success resets the count.
|
||||
*/
|
||||
function createLoadCoordinator(options) {
|
||||
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
|
||||
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
|
||||
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let retryHandle = null;
|
||||
let failures = 0;
|
||||
const clearRetry = () => {
|
||||
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
|
||||
retryHandle = null;
|
||||
@@ -237,12 +316,17 @@
|
||||
try {
|
||||
const layout = await options.fetchLayout();
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
failures = 0;
|
||||
options.applyLayout(layout);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
failures++;
|
||||
options.applyFallback();
|
||||
retryHandle = options.scheduleRetry(() => load());
|
||||
if (failures <= maxRetries) {
|
||||
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
|
||||
retryHandle = options.scheduleRetry(() => load(), delay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -260,6 +344,7 @@
|
||||
normalizeLayout,
|
||||
hasGroups,
|
||||
project,
|
||||
hiddenGroupAlerts,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
createLoadCoordinator,
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
|
||||
// focus itself, and the accessory bar, which is built to be used while the
|
||||
// keyboard is open.
|
||||
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
|
||||
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
|
||||
// headers are listed by role instead, or tapping one would drop the keyboard.
|
||||
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
|
||||
'input',
|
||||
'textarea',
|
||||
@@ -64,6 +67,7 @@
|
||||
'[contenteditable=""]',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="treeitem"]',
|
||||
'.keyboard-accessory-bar',
|
||||
'.path-picker-overlay',
|
||||
].join(',');
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*
|
||||
* DOM emulation cannot answer either question (hit testing, hover reveal and
|
||||
* layout are Chromium's), which is why this runs in a real browser. The shipping
|
||||
* app.js, webview-tabs.js and styles.css are loaded into a page; the CodemanApp
|
||||
* instance gets stub actions that only record what ran.
|
||||
* app.js, webview-tabs.js, terminal-ui.js (for the touch keyboard dismissal) and
|
||||
* styles.css are loaded into a page; the CodemanApp instance gets stub actions
|
||||
* that only record what ran.
|
||||
*
|
||||
* Port: none (page.setContent, no server).
|
||||
*/
|
||||
@@ -57,6 +58,7 @@ describe('tab row activation in Chromium', () => {
|
||||
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
|
||||
});
|
||||
await page.addScriptTag({ content: read('webview-tabs.js') });
|
||||
await page.addScriptTag({ content: read('terminal-ui.js') });
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = Object.create(w.CodemanApp.prototype);
|
||||
@@ -290,6 +292,88 @@ describe('tab row activation in Chromium', () => {
|
||||
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves Enter on a focused in-row control to that control in the tree', async () => {
|
||||
await render('vertical', true);
|
||||
// A click focuses the overflow button (the action menu hands focus back the
|
||||
// same way on Escape). Enter there must run the button, not re-select the row.
|
||||
await page.locator('[data-id="one"] .tab-more').click();
|
||||
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
|
||||
await reset();
|
||||
await page.keyboard.press('Enter');
|
||||
expect(await result()).toEqual({ activation: null, action: 'overflow' });
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the touch keyboard up when a tree header or unselected row is tapped', async () => {
|
||||
await render('vertical', true);
|
||||
const state = await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = w.__app;
|
||||
const input = document.createElement('textarea');
|
||||
input.className = 'xterm-helper-textarea';
|
||||
document.body.appendChild(input);
|
||||
app._installMobileKeyboardDismiss();
|
||||
// A finger that lifts where it landed (no travel): the handler reads only
|
||||
// `touches`, so a plain Event carrying them stands in for a TouchEvent.
|
||||
const tap = (target: Element) => {
|
||||
const at = (type: string, touches: Array<{ clientX: number; clientY: number }>) => {
|
||||
const ev = new Event(type, { bubbles: true });
|
||||
Object.defineProperty(ev, 'touches', { value: touches });
|
||||
target.dispatchEvent(ev);
|
||||
};
|
||||
at('touchstart', [{ clientX: 10, clientY: 10 }]);
|
||||
at('touchend', []);
|
||||
};
|
||||
const keptAfterTap = (selector: string) => {
|
||||
input.focus();
|
||||
tap(document.querySelector(selector)!);
|
||||
return document.activeElement === input;
|
||||
};
|
||||
const out = {
|
||||
header: keptAfterTap('[data-tab-group-header="g"] .tab-layout-group-name'),
|
||||
unselectedRow: keptAfterTap('[data-webview-id="web"] .tab-name'),
|
||||
inertChrome: false,
|
||||
};
|
||||
// Inert chrome still dismisses, so the handler is live.
|
||||
out.inertChrome = !keptAfterTap('.tab-layout-ungrouped-header');
|
||||
document.removeEventListener('touchstart', app._mobileKeyboardDismissStart);
|
||||
document.removeEventListener('touchmove', app._mobileKeyboardDismissMove);
|
||||
document.removeEventListener('touchend', app._mobileKeyboardDismissHandler);
|
||||
app._mobileKeyboardDismissHandler = null;
|
||||
input.remove();
|
||||
return out;
|
||||
});
|
||||
expect(state).toEqual({ header: true, unselectedRow: true, inertChrome: true });
|
||||
});
|
||||
|
||||
it('rings a collapsed header in the tab alert colour of the row it hides', async () => {
|
||||
await render('vertical', true);
|
||||
const ring = await page.evaluate(() => {
|
||||
const app = (window as any).__app;
|
||||
// "two" is the selection, so collapsing the group hides "one".
|
||||
app.activeSessionId = 'two';
|
||||
app.tabAlerts.set('one', 'action');
|
||||
app.toggleTabGroupCollapsed('g', true);
|
||||
const header = document.querySelector('[data-tab-group-header="g"]')!;
|
||||
const before = getComputedStyle(header, '::before');
|
||||
const out = {
|
||||
hidden: !document.querySelector('[data-id="one"]'),
|
||||
className: header.classList.contains('tab-alert-action'),
|
||||
content: before.content,
|
||||
borderColor: before.borderTopColor,
|
||||
};
|
||||
app.tabAlerts.clear();
|
||||
app.toggleTabGroupCollapsed('g', false);
|
||||
return out;
|
||||
});
|
||||
expect(ring.hidden).toBe(true);
|
||||
expect(ring.className).toBe(true);
|
||||
expect(ring.content).not.toBe('none');
|
||||
// --red, whatever the skin resolves it to: a real colour, not transparent.
|
||||
expect(ring.borderColor).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/);
|
||||
});
|
||||
|
||||
it('keeps tab semantics and no tree roles without groups', async () => {
|
||||
await render('vertical', false);
|
||||
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
|
||||
|
||||
@@ -104,6 +104,16 @@ describe('projection', () => {
|
||||
expect(collapsed.sections[0].count).toBe(3);
|
||||
expect(collapsed.sections[0].collapsed).toBe(true);
|
||||
expect(collapsed.hiddenTabGroupByRef).toEqual({ 'session:s2': 'g1', 'webview:w1': 'g1' });
|
||||
expect(ids(collapsed.sections[0].hidden)).toEqual(['session:s2', 'webview:w1']);
|
||||
// Every placed row knows its section, shown or hidden (null = Ungrouped).
|
||||
expect(collapsed.sectionByRef).toEqual({
|
||||
'session:s2': 'g1',
|
||||
'webview:w1': 'g1',
|
||||
'session:s1': 'g1',
|
||||
'session:s3': 'g2',
|
||||
'webview:w2': null,
|
||||
'session:s4': null,
|
||||
});
|
||||
|
||||
// An active web tab owns the highlight even while a session stays selected.
|
||||
const web = h.project(layout(), { ...common, activeSessionId: 's1', activeWebviewId: 'w1' });
|
||||
@@ -117,13 +127,65 @@ describe('structure key', () => {
|
||||
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
|
||||
const base = h.structureKey(layout(), h.project(layout(), opts), []);
|
||||
expect(h.structureKey(layout(), h.project(layout(), opts), [])).toBe(base);
|
||||
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).not.toBe(base);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, collapsedGroupIds: ['g1'] }), ['g1'])).not.toBe(
|
||||
base
|
||||
);
|
||||
expect(h.structureKey(layout(), h.project(layout(), { ...opts, liveSessionIds: ['s1', 's2'] }), [])).not.toBe(base);
|
||||
const renamed = { ...layout(), groups: [{ ...layout().groups[0], name: 'Renamed' }, layout().groups[1]] };
|
||||
expect(h.structureKey(renamed, h.project(renamed, opts), [])).not.toBe(base);
|
||||
expect(h.structureKey(layout(), null, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a version bump that moves nothing, so a lifecycle broadcast costs no rebuild', () => {
|
||||
const h = loadHelper();
|
||||
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
|
||||
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).toBe(
|
||||
h.structureKey(layout(4), h.project(layout(4), opts), [])
|
||||
);
|
||||
});
|
||||
|
||||
it('changes when a collapse hides a different row, even with equal counts and shown rows', () => {
|
||||
const h = loadHelper();
|
||||
const two = (refs: Ref[][]) => ({
|
||||
version: 1,
|
||||
groups: [
|
||||
{ id: 'a', name: 'A', refs: refs[0] },
|
||||
{ id: 'b', name: 'B', refs: refs[1] },
|
||||
],
|
||||
ungrouped: [],
|
||||
});
|
||||
const s = (id: string): Ref => ({ kind: 'session', id });
|
||||
const opts = { liveSessionIds: ['x', 'y'], openWebviewIds: [], collapsedGroupIds: ['a', 'b'] };
|
||||
const before = two([[s('x')], [s('y')]]);
|
||||
const after = two([[s('y')], [s('x')]]);
|
||||
// Lineage anchors a hidden row to its header, so who hides where is structure.
|
||||
expect(h.structureKey(after, h.project(after, opts), ['a', 'b'])).not.toBe(
|
||||
h.structureKey(before, h.project(before, opts), ['a', 'b'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapsed header alerts', () => {
|
||||
it('reports the most urgent alert among the session rows a collapse hides', () => {
|
||||
const h = loadHelper();
|
||||
const alerts = new Map([
|
||||
['s2', 'idle'],
|
||||
['s1', 'action'],
|
||||
['s3', 'idle'],
|
||||
['s4', 'action'],
|
||||
]);
|
||||
const alertOf = (id: string) => alerts.get(id);
|
||||
const common = { liveSessionIds: ['s1', 's2', 's3', 's4'], openWebviewIds: ['w1', 'w2'] };
|
||||
// s1 is the kept selection, so it draws its own ring: only s2 (idle) is behind g1.
|
||||
const kept = h.project(layout(), { ...common, collapsedGroupIds: ['g1', 'g2'], activeSessionId: 's1' });
|
||||
expect(h.hiddenGroupAlerts(kept, alertOf)).toEqual({ g1: 'idle', g2: 'idle' });
|
||||
// With s1 hidden too, the red one wins over the yellow one.
|
||||
const hidden = h.project(layout(), { ...common, collapsedGroupIds: ['g1'], activeSessionId: 's4' });
|
||||
expect(h.hiddenGroupAlerts(hidden, alertOf)).toEqual({ g1: 'action' });
|
||||
// Expanded groups and the ungrouped section never report (their rows are on screen).
|
||||
expect(h.hiddenGroupAlerts(h.project(layout(), common), alertOf)).toEqual({});
|
||||
expect(h.hiddenGroupAlerts(null, alertOf)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device collapse storage', () => {
|
||||
@@ -152,7 +214,7 @@ describe('per-device collapse storage', () => {
|
||||
expect(local.setItem).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '["g1"]');
|
||||
});
|
||||
|
||||
it('reports failure (all-expanded) on unreadable, malformed or unwritable storage', () => {
|
||||
it('reports failure (all-expanded) on unreadable or unwritable storage', () => {
|
||||
const h = loadHelper();
|
||||
const throwing = {
|
||||
getItem: () => {
|
||||
@@ -163,9 +225,23 @@ describe('per-device collapse storage', () => {
|
||||
},
|
||||
};
|
||||
expect(h.loadCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
expect(h.loadCollapsedGroupIds(storage('{"g1":true}'), ['g1'])).toEqual({ ids: [], ok: false });
|
||||
expect(h.saveCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
|
||||
});
|
||||
|
||||
it('reads a malformed stored value as nothing collapsed and repairs it, so collapse keeps working', () => {
|
||||
const h = loadHelper();
|
||||
for (const bad of ['{"g1":true}', 'not json', '"g1"', 'null']) {
|
||||
const local = storage(bad);
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: [], ok: true });
|
||||
expect(local.setItem, bad).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '[]');
|
||||
expect(h.saveCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
|
||||
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
|
||||
}
|
||||
// Without a group list to validate against, nothing is written.
|
||||
const untouched = storage('{"g1":true}');
|
||||
expect(h.loadCollapsedGroupIds(untouched)).toEqual({ ids: [], ok: true });
|
||||
expect(untouched.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped markup', () => {
|
||||
@@ -224,6 +300,27 @@ describe('grouped markup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty groups', () => {
|
||||
it('renders a group with no open rows as a tree leaf, expanded or collapsed', () => {
|
||||
const h = loadHelper();
|
||||
for (const collapsedGroupIds of [[], ['g2']]) {
|
||||
const projection = h.project(layout(), { liveSessionIds: ['s1', 's2'], openWebviewIds: [], collapsedGroupIds });
|
||||
expect(projection.sections[1]).toMatchObject({ id: 'g2', count: 0, refs: [] });
|
||||
const html = h.renderProjection(projection, () => '', escape);
|
||||
const doc = new JSDOM(`<main>${html}</main>`).window.document;
|
||||
const header = doc.querySelector('[data-tab-group-header="g2"]')!;
|
||||
expect(header.getAttribute('role')).toBe('treeitem');
|
||||
expect(header.hasAttribute('aria-expanded')).toBe(false);
|
||||
expect(header.hasAttribute('aria-owns')).toBe(false);
|
||||
expect(header.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
|
||||
'presentation'
|
||||
);
|
||||
// The populated group is unaffected.
|
||||
expect(doc.querySelector('[data-tab-group-header="g1"]')!.getAttribute('aria-expanded')).toBe('true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('load coordination', () => {
|
||||
it('applies only the newest response when loads overlap', async () => {
|
||||
const h = loadHelper();
|
||||
@@ -271,6 +368,51 @@ describe('load coordination', () => {
|
||||
expect(applyLayout).toHaveBeenCalledWith(layout(7));
|
||||
});
|
||||
|
||||
it('backs off failed retries, stops after the cap, and starts over after a success', async () => {
|
||||
const h = loadHelper();
|
||||
const delays: number[] = [];
|
||||
let pending: (() => Promise<boolean>) | null = null;
|
||||
let failing = true;
|
||||
const applyFallback = vi.fn();
|
||||
const coordinator = h.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
if (failing) throw new Error('offline');
|
||||
return layout(1);
|
||||
},
|
||||
applyLayout: vi.fn(),
|
||||
applyFallback,
|
||||
retryDelayMs: 5000,
|
||||
maxRetryDelayMs: 30000,
|
||||
maxRetries: 4,
|
||||
scheduleRetry: (fn: () => Promise<boolean>, delay: number) => {
|
||||
delays.push(delay);
|
||||
pending = fn;
|
||||
return delays.length;
|
||||
},
|
||||
cancelRetry: vi.fn(),
|
||||
});
|
||||
await coordinator.load();
|
||||
// Bounded drain: an uncapped retry must fail here, not spin forever (the
|
||||
// loop only awaits microtasks, so vitest's own timeout could never fire).
|
||||
for (let drained = 0; pending && drained < 10; drained++) {
|
||||
const next: () => Promise<boolean> = pending;
|
||||
pending = null;
|
||||
await next();
|
||||
}
|
||||
// Four retries (5 s doubling, capped at 30 s), then nothing more is scheduled.
|
||||
expect(delays).toEqual([5000, 10000, 20000, 30000]);
|
||||
expect(applyFallback).toHaveBeenCalledTimes(5);
|
||||
|
||||
// An outside load (SSE init, tab:layoutChanged) still tries, and a success
|
||||
// resets the count, so the next outage gets the full schedule again.
|
||||
failing = false;
|
||||
expect(await coordinator.load()).toBe(true);
|
||||
failing = true;
|
||||
await coordinator.load();
|
||||
expect(delays.at(-1)).toBe(5000);
|
||||
coordinator.dispose();
|
||||
});
|
||||
|
||||
it('cancels a pending retry and ignores in-flight results after dispose', async () => {
|
||||
const h = loadHelper();
|
||||
let resolve!: (value: unknown) => void;
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* owner's groups only when it is vertical AND there is at least one group (every
|
||||
* other case must be byte-for-byte the flat rail), collapse is per-device and
|
||||
* keeps the active row, a structural change escapes the incremental patch path,
|
||||
* drag-reorder is withheld, lineage arcs to a collapse-hidden session anchor
|
||||
* to its group header, and the grouped rail (only) is an ARIA tree with one
|
||||
* roving tab stop, a tree keyboard model and focus restored across rebuilds.
|
||||
* drag-reorder and cross-group Ctrl+Shift moves are withheld, a collapsed
|
||||
* header carries the alert it hides, layout reads rebuild only on a structural
|
||||
* change and back off when they fail, lineage arcs to a collapse-hidden session
|
||||
* anchor to its group header, and the grouped rail (only) is an ARIA tree with
|
||||
* one roving tab stop, a tree keyboard model and focus restored across rebuilds.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document`, `localStorage` and `window` below are that window's, not Node's.
|
||||
@@ -17,6 +19,7 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -277,6 +280,76 @@ describe('grouped vertical rail', () => {
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps collapse working after a malformed stored value, and repairs it', () => {
|
||||
localStorage.setItem('codeman:tab-groups-collapsed', '{"group-x":true}');
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._applyTabLayout(layout);
|
||||
expect(app._tabCollapseStorageFailed).toBe(false);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
|
||||
expect(app.toggleTabGroupCollapsed('group-x', true)).toBe(true);
|
||||
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('lights a collapsed header with the most urgent alert it hides, on both render paths', () => {
|
||||
const app = makeApp();
|
||||
app.tabAlerts.set('s1', 'idle');
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
const header = () => document.querySelector('[data-tab-group-header="group-x"]')!;
|
||||
const ring = () => ['tab-alert-action', 'tab-alert-idle'].filter((c) => header().classList.contains(c));
|
||||
// s1 is hidden (s2 is the kept selection): its yellow alert moves to the header.
|
||||
expect(rowIds()).toEqual(['s2', 's3']);
|
||||
expect(ring()).toEqual(['tab-alert-idle']);
|
||||
|
||||
// A red alert arriving later is patched in place, without a rebuild.
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.tabAlerts.set('s1', 'action');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(ring()).toEqual(['tab-alert-action']);
|
||||
|
||||
// The kept selection draws its own ring, so its alert never reaches the header.
|
||||
app.tabAlerts.clear();
|
||||
app.tabAlerts.set('s2', 'action');
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(ring()).toEqual([]);
|
||||
expect(document.querySelector('[data-id="s2"]')!.classList.contains('tab-alert-action')).toBe(true);
|
||||
|
||||
// Expanded, every row shows its own alert and the header shows none.
|
||||
app.tabAlerts.set('s1', 'action');
|
||||
app.toggleTabGroupCollapsed('group-x', false);
|
||||
expect(ring()).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets Ctrl+Shift moves swap only within the active session's own section", () => {
|
||||
const app = makeApp();
|
||||
app.saveSessionOrder = vi.fn();
|
||||
app._fullRenderSessionTabs();
|
||||
// s2's neighbours in sessionOrder (s1,s2,s3): s3 is Ungrouped, s1 shares group-x.
|
||||
app.moveActiveTabRight();
|
||||
expect(app.sessionOrder).toEqual(['s1', 's2', 's3']);
|
||||
expect(app.saveSessionOrder).not.toHaveBeenCalled();
|
||||
app.moveActiveTabLeft();
|
||||
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
|
||||
expect(app.saveSessionOrder).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A row a collapse hides is still in its section.
|
||||
app.sessionOrder = ['s1', 's2', 's3'];
|
||||
app.collapsedTabGroupIds = new Set(['group-x']);
|
||||
app._fullRenderSessionTabs();
|
||||
expect(rowIds()).not.toContain('s1');
|
||||
app.moveActiveTabLeft();
|
||||
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
|
||||
|
||||
// The flat rail (and the strip) keep moving across every neighbour.
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat.saveSessionOrder = vi.fn();
|
||||
flat._fullRenderSessionTabs();
|
||||
flat.moveActiveTabRight();
|
||||
expect(flat.sessionOrder).toEqual(['s1', 's3', 's2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout adoption', () => {
|
||||
@@ -294,6 +367,67 @@ describe('layout adoption', () => {
|
||||
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
|
||||
});
|
||||
|
||||
it('rebuilds the strip on adoption only when what the rail draws changed', () => {
|
||||
const flat = makeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
const flatRender = vi.spyOn(flat, '_fullRenderSessionTabs');
|
||||
// The flat rail (every owner without groups) never rebuilds for a layout read.
|
||||
flat._applyTabLayout({ version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] });
|
||||
flat._applyTabLayout({ version: 4, groups: [], ungrouped: [] });
|
||||
flat._applyTabLayout(null);
|
||||
expect(flatRender).not.toHaveBeenCalled();
|
||||
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
const render = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app._applyTabLayout(layout);
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
// A version bump that moves nothing (the broadcast after any session event).
|
||||
app._applyTabLayout({ ...layout, version: 9 });
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
// A rename does change the rail.
|
||||
app._applyTabLayout({ ...layout, version: 10, groups: [{ ...layout.groups[0], name: 'Ops' }] });
|
||||
expect(render).toHaveBeenCalledTimes(2);
|
||||
expect(document.querySelector('.tab-layout-group-name')?.textContent).toBe('Ops');
|
||||
// Grouped to flat (a failed read) rebuilds once.
|
||||
app._applyTabLayout(null);
|
||||
expect(render).toHaveBeenCalledTimes(3);
|
||||
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('backs off failed layout reads and stops, without rebuilding a flat rail', async () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
app._tabLayoutCoordinator = null;
|
||||
app._apiJson = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
const render = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
const realSetTimeout = window.setTimeout;
|
||||
const delays: number[] = [];
|
||||
let queued: (() => Promise<boolean>) | null = null;
|
||||
window.setTimeout = (fn: () => Promise<boolean>, ms: number) => {
|
||||
delays.push(ms);
|
||||
queued = fn;
|
||||
return delays.length;
|
||||
};
|
||||
try {
|
||||
await app._loadTabLayout();
|
||||
// Bounded: an uncapped retry must fail the assertion below, not hang.
|
||||
for (let drained = 0; queued && drained < 10; drained++) {
|
||||
const next: () => Promise<boolean> = queued;
|
||||
queued = null;
|
||||
await next();
|
||||
}
|
||||
} finally {
|
||||
window.setTimeout = realSetTimeout;
|
||||
}
|
||||
expect(delays).toEqual([5000, 10000, 20000, 40000]);
|
||||
expect(app._apiJson).toHaveBeenCalledTimes(5);
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
app._tabLayoutCoordinator.dispose();
|
||||
});
|
||||
|
||||
it('ignores an older layout than the one it already shows', () => {
|
||||
const app = makeApp();
|
||||
app.tabLayout = { ...layout, version: 9 };
|
||||
@@ -503,9 +637,90 @@ describe('grouped rail tree semantics', () => {
|
||||
press('Home');
|
||||
press('Enter');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
|
||||
// The header was rebuilt by the toggle; focus followed it, never to <body>.
|
||||
expect(focused()).toBe('group:group-x');
|
||||
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves keys pressed on a control inside a row to that control', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
// The overflow button gets focus from a click, or back from its action menu.
|
||||
const more = row('s2').querySelector<HTMLElement>('.tab-more')!;
|
||||
more.focus();
|
||||
expect(press('Enter')).toBe(true);
|
||||
expect(press(' ')).toBe(true);
|
||||
expect(press('ArrowDown')).toBe(true);
|
||||
expect(press('ArrowLeft')).toBe(true);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(document.activeElement).toBe(more);
|
||||
});
|
||||
|
||||
it('treats a group with no open rows as a leaf that Left/Right cannot open or close', () => {
|
||||
const app = makeTreeApp({
|
||||
tabLayout: { ...layout, groups: [...layout.groups, { id: 'empty', name: 'Empty', refs: [] }] },
|
||||
});
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="empty"]')!;
|
||||
expect(header.hasAttribute('aria-expanded')).toBe(false);
|
||||
expect(header.hasAttribute('aria-owns')).toBe(false);
|
||||
header.focus();
|
||||
press('ArrowRight');
|
||||
press('ArrowLeft');
|
||||
expect(app.collapsedTabGroupIds.size).toBe(0);
|
||||
expect(focused()).toBe('group:empty');
|
||||
});
|
||||
|
||||
it('keeps tree positions in painted order when the sorted rail re-sorts rows in place', () => {
|
||||
const app = makeTreeApp();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
let order = new Map([
|
||||
['s2', 0],
|
||||
['s1', 1],
|
||||
['s3', 2],
|
||||
]);
|
||||
app._tabRailSortOrder = () => order;
|
||||
app._fullRenderSessionTabs();
|
||||
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
|
||||
app._applyTabTreePositions(tabs());
|
||||
const pos = (id: string) => row(id).getAttribute('aria-posinset');
|
||||
expect([pos('s2'), pos('s1'), pos('w1')]).toEqual(['1', '2', '3']);
|
||||
|
||||
// s1 goes working: the incremental pass moves its card up with `order` only.
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
order = new Map([
|
||||
['s1', 0],
|
||||
['s2', 1],
|
||||
['s3', 2],
|
||||
]);
|
||||
app._renderSessionTabsImmediate();
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect(row('s1').style.order).toBe('0');
|
||||
expect([pos('s1'), pos('s2'), pos('w1')]).toEqual(['1', '2', '3']);
|
||||
expect(row('s1').getAttribute('aria-setsize')).toBe('3');
|
||||
});
|
||||
|
||||
it('exempts tree items from the touch keyboard dismissal, like every other control', () => {
|
||||
// terminal-ui.js publishes the selector on window.CodemanTerminalInput; its
|
||||
// top level only needs a window and a CodemanApp prototype to assign to.
|
||||
const context = vm.createContext({ window: {}, CodemanApp: function CodemanApp() {} });
|
||||
vm.runInContext(readFileSync(join(PUBLIC, 'terminal-ui.js'), 'utf8'), context);
|
||||
const selector = (context.window as any).CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR as string;
|
||||
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
// The roving stop sits on s2: the header and the other rows are tabindex=-1.
|
||||
expect(header.tabIndex).toBe(-1);
|
||||
expect(row('s1').tabIndex).toBe(-1);
|
||||
for (const target of [header, header.querySelector('.tab-layout-group-name')!, row('s1'), row('w1')]) {
|
||||
expect(target.closest(selector), (target as HTMLElement).className).not.toBeNull();
|
||||
}
|
||||
// Inert rail chrome still dismisses.
|
||||
expect(document.querySelector('.tab-layout-ungrouped-header')!.closest(selector)).toBeNull();
|
||||
});
|
||||
|
||||
it('opens row actions from the keyboard, since its controls left the tab order', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
|
||||
Reference in New Issue
Block a user