mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
feat(tabs): Tab Layout setting with by case (A) and ledger (B), reversible state order, cleaner tiles (#426)
Tab Grouping becomes Tab Layout (tabArrangement: 'state' | 'case' | 'ledger' | 'classic', default 'state'), the first row of App Settings, Appearance, Tabs, so the old and the new strip are one choice apart. - By case (option A): each case's tabs sit in one .tab-cluster box in first-appearance order, labelled with the case and its count, coloured by a stable hash into the session palette. Membership is _mobileOverviewCaseFor(), the home screens' own match. Inside a box with company a generated w75-api-gateway reads w75; the -<case> stays in the DOM in a .tab-name-case span only .tabs-clusters hides. The incremental render path rebuilds only when the cluster structure key changes. The rail and the sidebar get a labelled section per case; phones dissolve the boxes into the chip row. Drag stays inside one box. - Ledger (option B): CSS only on .tabs-ledger, desktop header strip: an auto-fill column grid of equal cells in mono type with a 3px status bar. Its markup is identical to classic's. - State Order (tabStateOrder: 'urgent-first' | 'urgent-last'): flips the by-state groups so needs you can be the bottom row. - By state: the label column is measured to the widest label on screen and the labels are right-aligned in it, instead of a fixed 92px gutter that left short labels far from their tabs. - Tiles: a three-row grid (label, value, bar) with pixel line-heights in the bundled JetBrains Mono, 36px like the header. The bar used to lie over a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it. The WS tile's grid moved onto an inner .connection-tile span because JS writes the indicator's display inline. Compact uses the same font and a matched WS size. Tests: test/tab-clusters.test.ts (new), plus the rename and the reversed order in test/tab-triage.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+210
-23
@@ -4797,7 +4797,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the tab list groups by state (`tabGrouping: 'state'`, the default;
|
||||
* True when the tab list groups by state (`tabArrangement: 'state'`, the default;
|
||||
* Discussion #426 option C): a row per state in the header strip, a section
|
||||
* per state in the flat side rail and the sidebar, most urgent on top.
|
||||
*
|
||||
@@ -4807,7 +4807,7 @@ class CodemanApp {
|
||||
* grouped projection is on, and the grouped tree renders as it always did.
|
||||
*/
|
||||
isTabTriage() {
|
||||
return document.documentElement.dataset.tabGrouping === 'state';
|
||||
return document.documentElement.dataset.tabArrangement === 'state';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4840,7 +4840,8 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
return window.CodemanTabTriage.layout(rows, webviewIds);
|
||||
const reverse = document.documentElement.dataset.tabStateOrder === 'urgent-last';
|
||||
return window.CodemanTabTriage.layout(rows, webviewIds, { reverse });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4860,6 +4861,7 @@ class CodemanApp {
|
||||
*/
|
||||
_syncTabTriageChrome(container, triage) {
|
||||
if (!container) return;
|
||||
this._lastTabTriage = triage;
|
||||
container.classList.toggle('tabs-triage', !!triage);
|
||||
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
|
||||
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
|
||||
@@ -4899,25 +4901,184 @@ class CodemanApp {
|
||||
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
|
||||
if (web.style.order !== value) web.style.order = value;
|
||||
}
|
||||
this._sizeTabTriageGutter(container, triage);
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag in a state-grouped strip may only reorder WITHIN a group. Inside a
|
||||
* group the rows sit in tab order, so a drop there moves the tab exactly where
|
||||
* it was dropped; across groups the dragged tab would stay in its own group
|
||||
* (its state did not change) and land somewhere the user did not put it.
|
||||
* Groups are bands of `order` values, so comparing bands is enough.
|
||||
* Size the header strip's label column to the widest label on screen, so no
|
||||
* row carries a fixed gutter's worth of empty space before its tabs (labels
|
||||
* are right-aligned in it, styles.css). Measured only when the label text
|
||||
* changes (a group appears, goes, or its count gains a digit), and once more
|
||||
* when the web fonts finish loading, since that changes every width. The
|
||||
* vertical lists do not use the gutter, so they are never measured.
|
||||
*/
|
||||
_isTabDropAcrossTriageGroups(targetTab) {
|
||||
_sizeTabTriageGutter(container, triage) {
|
||||
const inHeader = !!container.parentElement?.classList.contains('session-tabs-host');
|
||||
if (!triage || !inHeader) {
|
||||
if (container.style.getPropertyValue('--tab-triage-gutter')) container.style.removeProperty('--tab-triage-gutter');
|
||||
this._tabTriageGutterKey = null;
|
||||
return;
|
||||
}
|
||||
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
|
||||
if (key === this._tabTriageGutterKey) return;
|
||||
let widest = 0;
|
||||
for (const head of container.querySelectorAll(':scope > .tab-triage-head')) {
|
||||
let width = 0;
|
||||
for (const part of head.children) width += part.getBoundingClientRect().width;
|
||||
widest = Math.max(widest, width + 5 * Math.max(0, head.children.length - 1));
|
||||
}
|
||||
// Hidden (display: none on phones, or a detached strip): nothing to size.
|
||||
if (!widest) return;
|
||||
this._tabTriageGutterKey = key;
|
||||
container.style.setProperty('--tab-triage-gutter', `${Math.ceil(widest + 10)}px`);
|
||||
if (!this._tabTriageFontsHooked && document.fonts?.ready) {
|
||||
this._tabTriageFontsHooked = true;
|
||||
document.fonts.ready.then(() => {
|
||||
this._tabTriageGutterKey = null;
|
||||
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag in a grouped strip (by state or by case) may only reorder WITHIN a
|
||||
* group. Inside a group the rows sit in tab order, so a drop there moves the
|
||||
* tab exactly where it was dropped; across groups the dragged tab would stay
|
||||
* in its own group (its state or case did not change) and land somewhere the
|
||||
* user did not put it. State groups are bands of `order` values, so comparing
|
||||
* bands is enough; case clusters are boxes, so the box decides.
|
||||
*/
|
||||
_isTabDropAcrossGroups(targetTab) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container?.classList.contains('tabs-triage') || !this.draggedTabId || !targetTab) return false;
|
||||
if (!container || !this.draggedTabId || !targetTab) return false;
|
||||
const triage = container.classList.contains('tabs-triage');
|
||||
const clusters = container.classList.contains('tabs-clusters');
|
||||
if (!triage && !clusters) return false;
|
||||
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
|
||||
if (!dragged) return false;
|
||||
// Clusters are real boxes: a drop belongs to the box it lands in.
|
||||
if (clusters) return dragged.closest('.tab-cluster') !== targetTab.closest('.tab-cluster');
|
||||
const stride = window.CodemanTabTriage?.STRIDE || 10000;
|
||||
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
|
||||
return band(dragged) !== band(targetTab);
|
||||
}
|
||||
|
||||
/** True when the tab list is clustered by case (`tabArrangement: 'case'`, Discussion #426 option A). */
|
||||
isTabClusters() {
|
||||
return document.documentElement.dataset.tabArrangement === 'case';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the header strip is drawn as a ledger (`tabArrangement: 'ledger'`,
|
||||
* Discussion #426 option B): the flat list on an aligned column grid with a
|
||||
* status bar per cell. Pure CSS on `.tabs-ledger`, scoped to the desktop
|
||||
* header strip; the rail and the sidebar keep their flat list.
|
||||
*/
|
||||
isTabLedger() {
|
||||
return document.documentElement.dataset.tabArrangement === 'ledger';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which case a session belongs to, for clustering: the case whose path is the
|
||||
* longest prefix of its working directory (`_mobileOverviewCaseFor()`, the
|
||||
* home screens' own match), else the directory itself, else the session alone.
|
||||
*/
|
||||
_tabClusterIdentity(session, id) {
|
||||
const dir = (session.workingDir || '').replace(/\/+$/, '');
|
||||
const match =
|
||||
dir && typeof this._mobileOverviewCaseFor === 'function' ? this._mobileOverviewCaseFor(dir, this.cases) : null;
|
||||
if (match) return { key: match.path, label: match.name || '' };
|
||||
if (dir) return { key: dir, label: dir.split('/').pop() || dir };
|
||||
return { key: `session:${id}`, label: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster layout for one render pass, or null when the list is not
|
||||
* clustered. Named groups in the vertical rail win, exactly as for the state
|
||||
* grouping. `key` is the whole structure as a string: the incremental render
|
||||
* path compares it with the last full render's and rebuilds when it differs,
|
||||
* because a cluster is a real box and a patch in place cannot move a tab into
|
||||
* another one. Membership only changes when sessions come and go (already a
|
||||
* full rebuild) or when the case list arrives, so this rarely fires.
|
||||
*
|
||||
* @param {Array<string>} ids live session ids, in tab order
|
||||
* @param {object|null} groupProjection the grouped rail's projection, if any
|
||||
*/
|
||||
_tabClusterLayout(ids, groupProjection) {
|
||||
if (groupProjection || !this.isTabClusters() || !window.CodemanTabClusters) return null;
|
||||
const rows = [];
|
||||
for (const id of ids) {
|
||||
const session = this.sessions.get(id);
|
||||
if (session) rows.push({ id, ...this._tabClusterIdentity(session, id) });
|
||||
}
|
||||
const clusters = window.CodemanTabClusters.compute(rows);
|
||||
// Only a cluster with company drops the case from its tab names.
|
||||
const labelFor = new Map();
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.ids.length > 1) for (const id of cluster.ids) labelFor.set(id, cluster.label);
|
||||
}
|
||||
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
|
||||
const key = JSON.stringify([clusters.map((c) => [c.key, c.label, c.ids]), webviewIds]);
|
||||
return { clusters, labelFor, webviewIds, key };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cluster boxes for the full render: one box per case, labelled with its
|
||||
* colour swatch, name and count, and a box per open web tab, which has no
|
||||
* case. The header strip shows only the swatch for a case with one tab
|
||||
* (styles.css); the rail and the sidebar label every case. Rows are the
|
||||
* caller's own markup, so a tab is byte-identical to the flat strip's apart
|
||||
* from its name split.
|
||||
*/
|
||||
_renderTabClusters(layout, rowHtml, webviewSlotStart) {
|
||||
const parts = [];
|
||||
for (const cluster of layout.clusters) {
|
||||
const rows = cluster.ids.map((id) => rowHtml.get(id) || '').join('');
|
||||
const single = cluster.ids.length < 2;
|
||||
const head =
|
||||
'<span class="tab-cluster-label" aria-hidden="true"><span class="tab-cluster-swatch"></span>' +
|
||||
`<span class="tab-cluster-name" data-i18n-skip>${escapeHtml(cluster.label)}</span>` +
|
||||
`<span class="tab-cluster-count">${cluster.ids.length}</span></span>`;
|
||||
parts.push(
|
||||
`<div class="tab-cluster${single ? ' tab-cluster--single' : ''}" role="presentation" data-cluster-key="${escapeHtml(cluster.key)}" style="--cluster-color: var(--session-${cluster.color})">${head}${rows}</div>`
|
||||
);
|
||||
}
|
||||
layout.webviewIds.forEach((wid, i) => {
|
||||
const tab = this.renderWebviewTab?.(wid, webviewSlotStart + i) || '';
|
||||
if (tab) parts.push(`<div class="tab-cluster tab-cluster--single tab-cluster--web" role="presentation">${tab}</div>`);
|
||||
});
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab label, as markup. #232: a described name (`w3-x: fix login`) shows
|
||||
* just the description, the generated id kept in a hidden prefix span. Inside
|
||||
* a case cluster a generated `w75-api-gateway` shows `w75`, the `-api-gateway`
|
||||
* kept in a `.tab-name-case` span that only `.tabs-clusters` hides, so the full
|
||||
* name stays in the DOM (copy, find-in-page, the rename editor).
|
||||
*/
|
||||
_tabNameHtml(name, clusterLabel) {
|
||||
const parsed = parseSessionPrefix(name);
|
||||
if (parsed && parsed.suffix) {
|
||||
return `<span class="tab-name-prefix">${escapeHtml(parsed.prefix)}: </span>${escapeHtml(parsed.suffix)}`;
|
||||
}
|
||||
const split = clusterLabel ? window.CodemanTabClusters?.nameSplit(name, clusterLabel) : null;
|
||||
if (split) return `${escapeHtml(split.shown)}<span class="tab-name-case">${escapeHtml(split.hidden)}</span>`;
|
||||
return escapeHtml(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrangement classes on #sessionTabs that are not owned by a sync of
|
||||
* their own (`tabs-triage` is `_syncTabTriageChrome()`'s): `tabs-clusters`
|
||||
* while case clusters are drawn, `tabs-ledger` while the ledger is on. The
|
||||
* ledger never applies inside the grouped rail.
|
||||
*/
|
||||
_syncTabArrangementClasses(container, { clusters, groupProjection }) {
|
||||
if (!container) return;
|
||||
container.classList.toggle('tabs-clusters', !!clusters);
|
||||
container.classList.toggle('tabs-ledger', this.isTabLedger() && !groupProjection);
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
@@ -5581,10 +5742,16 @@ class CodemanApp {
|
||||
// The grouped rail's structure (sections, collapse, the shown exception) can
|
||||
// change while the id sets stay equal; the in-place patch below cannot move
|
||||
// or hide a row, so any structural change takes the full rebuild.
|
||||
// Case clusters are boxes too: the same rule, keyed on their structure.
|
||||
const clusterLayout = this._tabClusterLayout(
|
||||
this.sessionOrder.filter((sid) => this.sessions.has(sid)),
|
||||
groupProjection
|
||||
);
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged &&
|
||||
!this._isTabGroupStructureStale(groupProjection);
|
||||
!this._isTabGroupStructureStale(groupProjection) &&
|
||||
(clusterLayout ? clusterLayout.key : null) === (this._lastTabClusterKey ?? null);
|
||||
|
||||
if (canIncremental) {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
@@ -5707,11 +5874,17 @@ class CodemanApp {
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (nameEl.dataset.fullName !== name) {
|
||||
nameEl.replaceChildren();
|
||||
const _split = _p && _p.suffix ? null : window.CodemanTabClusters?.nameSplit(name, clusterLayout?.labelFor.get(id));
|
||||
if (_p && _p.suffix) {
|
||||
const prefix = document.createElement('span');
|
||||
prefix.className = 'tab-name-prefix';
|
||||
prefix.textContent = `${_p.prefix}: `;
|
||||
nameEl.append(prefix, document.createTextNode(_p.suffix));
|
||||
} else if (_split) {
|
||||
const caseSpan = document.createElement('span');
|
||||
caseSpan.className = 'tab-name-case';
|
||||
caseSpan.textContent = _split.hidden;
|
||||
nameEl.append(document.createTextNode(_split.shown), caseSpan);
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
}
|
||||
@@ -5787,6 +5960,7 @@ class CodemanApp {
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
|
||||
} else {
|
||||
// Full rebuild needed (sessions added/removed)
|
||||
this._fullRenderSessionTabs();
|
||||
@@ -5856,10 +6030,11 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
// Grouped by state, the header strip IS rows (one per state), so it always
|
||||
// wraps: the row breaks only take effect in a wrapping flex line. Narrower
|
||||
// screens keep the single scrolling row above, where the headings read as
|
||||
// inline dividers instead.
|
||||
if (container.classList.contains('tabs-triage')) {
|
||||
// wraps: the row breaks only take effect in a wrapping flex line. The ledger
|
||||
// is a grid of rows, so the same holds. Narrower screens keep the single
|
||||
// scrolling row above, where state headings read as inline dividers and the
|
||||
// ledger stays the plain strip.
|
||||
if (container.classList.contains('tabs-triage') || container.classList.contains('tabs-ledger')) {
|
||||
container.classList.add('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
@@ -5958,12 +6133,15 @@ class CodemanApp {
|
||||
// strip's markup is byte-identical to before.
|
||||
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
|
||||
const railSortOrder = this._tabRailSortOrder(liveIds);
|
||||
// Grouped by state (tabGrouping, the default): the same `order` mechanism,
|
||||
// Grouped by state (tabArrangement 'state', the default): the same `order` mechanism,
|
||||
// one band of values per state. Null in the grouped rail and with grouping
|
||||
// off, and the rows then carry exactly the inline order they did before.
|
||||
const groupProjection = this._projectTabGroups();
|
||||
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
|
||||
const listOrder = triage ? triage.order : railSortOrder;
|
||||
// Clustered by case: rows are wrapped in one box per case below, and a tab
|
||||
// in a cluster with company drops the `-<case>` from its name.
|
||||
const clusterLayout = this._tabClusterLayout(liveIds, groupProjection);
|
||||
// One row per session, in tab order. The flat strip emits them as-is; the
|
||||
// grouped rail places the SAME markup into its sections, so a row never
|
||||
// differs between the two (badge = Alt+N slot in sessionOrder either way).
|
||||
@@ -6005,9 +6183,7 @@ class CodemanApp {
|
||||
// JUST the description on the tab; the generated w<n>-<case> id moves to the
|
||||
// tooltip and stays visible in the session settings modal.
|
||||
const parsedName = parseSessionPrefix(name);
|
||||
const tabLabel = parsedName && parsedName.suffix
|
||||
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
|
||||
: escapeHtml(name);
|
||||
const tabLabel = this._tabNameHtml(name, clusterLayout?.labelFor.get(id));
|
||||
const tabTooltip = parsedName && parsedName.suffix
|
||||
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
|
||||
: (session.workingDir || '');
|
||||
@@ -6054,7 +6230,12 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
if (groupProjection) {
|
||||
if (clusterLayout) {
|
||||
// Clustered by case. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), each in a box of its own.
|
||||
parts.push(this._renderTabClusters(clusterLayout, rowHtml, _tabIdx));
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
} else if (groupProjection) {
|
||||
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), wherever their group puts them.
|
||||
const webviewSlots = new Map(
|
||||
@@ -6080,6 +6261,7 @@ class CodemanApp {
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
}
|
||||
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
|
||||
this._lastTabClusterKey = clusterLayout ? clusterLayout.key : null;
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
@@ -6089,6 +6271,7 @@ class CodemanApp {
|
||||
this._syncTabGroupHeaderAlerts(container, groupProjection);
|
||||
}
|
||||
this._syncTabTriageChrome(container, triage);
|
||||
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, 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
|
||||
@@ -6165,7 +6348,11 @@ class CodemanApp {
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
// Case clusters are boxes in DOM order and a sorted rail orders rows
|
||||
// INSIDE each one, so the box goes first in the key.
|
||||
const boxes = [...container.querySelectorAll(':scope > .tab-cluster')];
|
||||
const boxOf = (el) => boxes.indexOf(el.closest('.tab-cluster'));
|
||||
tabs.sort((a, b) => boxOf(a) - boxOf(b) || orderOf(a) - orderOf(b));
|
||||
}
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
@@ -7366,7 +7553,7 @@ class CodemanApp {
|
||||
tab.addEventListener('dragover', (e) => {
|
||||
// Grouped by state: a tab in another group is not a drop target, and
|
||||
// leaving the event alone (no preventDefault) is what shows "no drop".
|
||||
if (this._isTabDropAcrossTriageGroups(tab)) return;
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
e.preventDefault();
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
|
||||
@@ -7396,7 +7583,7 @@ class CodemanApp {
|
||||
tab.classList.remove('drag-over-left', 'drag-over-right');
|
||||
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
if (this._isTabDropAcrossTriageGroups(tab)) return;
|
||||
if (this._isTabDropAcrossGroups(tab)) return;
|
||||
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
Reference in New Issue
Block a user