mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #338 from Ark0N/feat/vertical-rail-detailed-rows
Vertical tab rail: detailed rows (created / working / status), plus a rename-cancel fix
This commit is contained in:
@@ -125,6 +125,45 @@ describe('Inline rename input', () => {
|
||||
expect(renameStillActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Escape cancels the rename instead of committing an empty name', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('esc-cancel', 'rail-beta')).toBe(true);
|
||||
|
||||
// Escape used to clear the field and blur, and the blur handler commits —
|
||||
// so cancelling a rename PUT an empty name, and the tab fell back to its
|
||||
// folder label (measured against a live server, in the header strip as well
|
||||
// as both vertical layouts). The observable here is the REQUEST: this
|
||||
// harness's server has no such session, so a failed PUT would leave the
|
||||
// local map looking innocent.
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
|
||||
const calls: string[] = [];
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL) => {
|
||||
calls.push(String(input));
|
||||
return new Response('{"success":true}', { status: 200 });
|
||||
}) as typeof window.fetch;
|
||||
|
||||
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||
inputEl.value = 'typed-but-abandoned';
|
||||
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
// The blur that follows the input's removal must not resurrect the commit.
|
||||
inputEl.dispatchEvent(new Event('blur'));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
window.fetch = origFetch;
|
||||
return {
|
||||
renamePuts: calls.filter((url) => url.includes('/api/sessions/esc-cancel/name')),
|
||||
renameActive: !!app._activeRename,
|
||||
inputStillInDom: document.body.contains(inputEl),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.renamePuts).toEqual([]);
|
||||
expect(result.renameActive).toBe(false);
|
||||
expect(result.inputStillInDom).toBe(false);
|
||||
});
|
||||
|
||||
it('CJK guard: regular Enter (no IME) DOES commit', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('regular-enter', 'OldName')).toBe(true);
|
||||
|
||||
@@ -54,6 +54,8 @@ interface LayoutApp {
|
||||
_fullRenderSessionTabs(): void;
|
||||
updateConnectionLines(): void;
|
||||
isSessionSidebarRich(): boolean;
|
||||
isTabRailRich(): boolean;
|
||||
isRichTabRows(): boolean;
|
||||
_sidebarRichRow(id: string, session: Record<string, unknown>): RichRow | null;
|
||||
_sidebarRichMetaHTML(row: RichRow | null): string;
|
||||
_updateSidebarRichRow(tab: Element, id: string, session: Record<string, unknown>): void;
|
||||
@@ -709,6 +711,22 @@ describe('rich session sidebar', () => {
|
||||
expect(html).toContain('data-i18n-skip');
|
||||
});
|
||||
|
||||
it('carries both absolute stamps on the LINE, not only on the two items', () => {
|
||||
// Below 288px the rail hides `.tab-meta-created` (tab-rail-tight), and a
|
||||
// title on a `display: none` element has no hover target — so a tooltip
|
||||
// living only there means the created stamp is gone, not shrunk. The line
|
||||
// itself has to carry it for the CSS rule's "still reachable" to be true.
|
||||
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
|
||||
stubOverview(app);
|
||||
const html = app._sidebarRichMetaHTML(app._sidebarRichRow('s1', SESSION));
|
||||
|
||||
const line = html.slice(0, html.indexOf('>'));
|
||||
expect(line).toContain('class="tab-meta"');
|
||||
expect(line).toContain('title="');
|
||||
expect(line).toContain('First created');
|
||||
expect(line).toContain('working');
|
||||
});
|
||||
|
||||
it('drops the second stamp when the session has never been active', () => {
|
||||
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
|
||||
stubOverview(app);
|
||||
@@ -795,7 +813,7 @@ describe('rich session sidebar', () => {
|
||||
// .tab-info is already a flex column, so the line needs no row-level
|
||||
// wrapping — and the collapsed 44px rail hides .tab-info wholesale, which is
|
||||
// what keeps the stamps out of it for free.
|
||||
expect(APP).toContain('const richRows = this.isSessionSidebarRich();');
|
||||
expect(APP).toContain('const richRows = this.isRichTabRows();');
|
||||
expect(APP).toContain('const richMeta = this._sidebarRichMetaHTML(richRow);');
|
||||
expect(APP).toContain('${richMeta}\n </span>');
|
||||
});
|
||||
@@ -819,3 +837,150 @@ describe('rich session sidebar', () => {
|
||||
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detailed rows in the vertical tab rail', () => {
|
||||
/**
|
||||
* The rail is the SECOND surface that draws rich rows. Everything about the
|
||||
* row itself (model, markup, clock) is shared with the sidebar and covered
|
||||
* above; what is new here is only the gate — which attribute turns it on,
|
||||
* and the three ways it must turn back off.
|
||||
*/
|
||||
const railBoot = (stored: Record<string, unknown>) => {
|
||||
const booted = boot({ stored: { sessionListLayout: 'header', ...stored } });
|
||||
booted.app.applySessionListLayout();
|
||||
booted.app.applyTabOrientation();
|
||||
return booted;
|
||||
};
|
||||
|
||||
it('defaults the rail to detailed rows, since a docked column is not a tab strip', () => {
|
||||
const { win, app } = railBoot({ tabOrientation: 'vertical' });
|
||||
expect(win.document.documentElement.dataset.tabOrientation).toBe('vertical');
|
||||
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
|
||||
expect(app.isTabRailRich()).toBe(true);
|
||||
expect(app.isRichTabRows()).toBe(true);
|
||||
// The stamps go stale with no event behind them, so the clock has to run.
|
||||
expect(app._sidebarRichClock).toBeTruthy();
|
||||
});
|
||||
|
||||
it("honors the 'simple' opt-out", () => {
|
||||
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
|
||||
expect(win.document.documentElement.dataset.tabRailDetail).toBe('simple');
|
||||
expect(app.isTabRailRich()).toBe(false);
|
||||
expect(app.isRichTabRows()).toBe(false);
|
||||
// Falsy rather than null: _stopSidebarRichClock() returns early when there
|
||||
// is no interval to clear, which is the state a rail that never armed one is in.
|
||||
expect(app._sidebarRichClock).toBeFalsy();
|
||||
});
|
||||
|
||||
it('drops back to simple rows once the rail is dragged into compact width', () => {
|
||||
// Below 240px the rail already hides the row actions; three lines of stamps
|
||||
// in a ~208px column ellipsize into noise. _setTabRailWidth() re-renders
|
||||
// whenever this class flips, so the gate is re-read at the right moment.
|
||||
const { win, app } = railBoot({ tabOrientation: 'vertical' });
|
||||
expect(app.isTabRailRich()).toBe(true);
|
||||
win.document.documentElement.classList.add('tab-rail-compact');
|
||||
expect(app.isTabRailRich()).toBe(false);
|
||||
expect(app.isRichTabRows()).toBe(false);
|
||||
});
|
||||
|
||||
it('never draws stamps in the horizontal header strip', () => {
|
||||
// tabRailDetail stays 'rich' in storage while the orientation is horizontal:
|
||||
// the gate has to read BOTH, or the header strip inherits a meta line that
|
||||
// has nowhere to go.
|
||||
const { win, app } = railBoot({ tabOrientation: 'horizontal', tabRailDetail: 'rich' });
|
||||
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
|
||||
expect(app.isTabRailRich()).toBe(false);
|
||||
expect(app.isRichTabRows()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the simple sidebar simple even with the rail set to detailed', () => {
|
||||
// The sidebar owns the tabs whenever it is active, which forces the
|
||||
// orientation back to horizontal — so a rail preference must not leak a
|
||||
// meta line into a list the user asked to keep compact.
|
||||
const { app } = railBoot({ sessionListLayout: 'sidebar', tabOrientation: 'vertical', tabRailDetail: 'rich' });
|
||||
expect(app.isSessionSidebarActive()).toBe(true);
|
||||
expect(app.isSessionSidebarRich()).toBe(false);
|
||||
expect(app.isTabRailRich()).toBe(false);
|
||||
expect(app.isRichTabRows()).toBe(false);
|
||||
});
|
||||
|
||||
it('re-renders when only the DETAIL changes, orientation untouched', () => {
|
||||
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
|
||||
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
|
||||
|
||||
win.localStorage.setItem(
|
||||
'codeman-app-settings',
|
||||
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'vertical', tabRailDetail: 'rich' })
|
||||
);
|
||||
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
|
||||
app.applyTabOrientation();
|
||||
|
||||
// The stamps line is emitted by the row template, not toggled by CSS: a
|
||||
// missed render here means the setting repaints nothing until the next tick.
|
||||
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
|
||||
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
|
||||
expect(app._sidebarRichClock).toBeTruthy();
|
||||
});
|
||||
|
||||
it('still renders on the first call, when applyTabWrapSettings only sets its baseline', () => {
|
||||
// The pre-paint script stamps the layout attributes; if it THREW it leaves
|
||||
// them on the catch-branch fallbacks and applyTabOrientation() is the first
|
||||
// thing to correct them, with `_tallTabsEnabled` still undefined.
|
||||
// applyTabWrapSettings() renders only when it has a previous value to
|
||||
// compare, so reading "the value changed" as "it rendered" skipped BOTH
|
||||
// renders and left the rows stale.
|
||||
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
|
||||
expect(app._tallTabsEnabled).toBeUndefined();
|
||||
expect(win.document.documentElement.getAttribute('data-tab-orientation')).toBeNull();
|
||||
|
||||
app.applyTabOrientation();
|
||||
|
||||
expect(win.document.documentElement.dataset.tabOrientation).toBe('vertical');
|
||||
// The folder row turned on in the same pass, so this is exactly the case
|
||||
// where the two guards could point at each other and neither fires.
|
||||
expect(app._tallTabsEnabled).toBe(true);
|
||||
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render twice when applyTabWrapSettings already did', () => {
|
||||
// The mirror case: a detail flip that turns the folder row off makes
|
||||
// applyTabWrapSettings() re-render, and applyTabOrientation() must not
|
||||
// stack a second full rebuild of the strip on top of it.
|
||||
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'rich' });
|
||||
expect(app._tallTabsEnabled).toBe(true);
|
||||
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
|
||||
|
||||
win.localStorage.setItem(
|
||||
'codeman-app-settings',
|
||||
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'vertical', tabRailDetail: 'simple' })
|
||||
);
|
||||
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
|
||||
app.applyTabOrientation();
|
||||
|
||||
expect(app._tallTabsEnabled).toBe(false);
|
||||
expect(app._fullRenderSessionTabs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('plumbs the rail detail through the settings UI, the schema and the pre-paint script', () => {
|
||||
expect(INDEX_HTML).toContain('id="appSettingsTabRailDetail"');
|
||||
expect(INDEX_HTML).toContain('<option value="rich">Detailed</option>');
|
||||
// Pre-paint stamps it with the rest of the layout keys, or a detailed rail
|
||||
// paints as a simple one for the first frame and then jumps a row taller.
|
||||
expect(INDEX_HTML).toContain("dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich'");
|
||||
expect(SETTINGS_UI).toContain("document.getElementById('appSettingsTabRailDetail').value");
|
||||
const displayKeys = SETTINGS_UI.slice(SETTINGS_UI.indexOf('const displayKeys = new Set(['));
|
||||
expect(displayKeys.slice(0, 1800)).toContain("'tabRailDetail'");
|
||||
expect(SCHEMAS).toMatch(/tabRailDetail:\s*z\.enum\(\['simple',\s*'rich'\]\)\.optional\(\)/);
|
||||
});
|
||||
|
||||
it('gives every rich paint rule a rail twin without raising the sidebar arm', () => {
|
||||
// Comma-grouped, never :is() — an :is() list takes its most specific
|
||||
// argument, which would lift the sidebar selectors from (0,3,1) to the
|
||||
// rail's (0,5,1) and let them outrank rules they never used to.
|
||||
const rail = "html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail";
|
||||
for (const suffix of ['.tab-meta', '.tab-meta-key', '.tab-pill', '.tab-pill--working']) {
|
||||
expect(STYLES_CSS).toContain(`${rail} ${suffix}`);
|
||||
}
|
||||
expect(STYLES_CSS).not.toContain(':is(html[data-sidebar-detail="rich"]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,10 @@ describe('tab rail width policy', () => {
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
|
||||
// Enter resets to the CALLER's effective default: a rich rail passes 320
|
||||
// (its unsized rendering width), so the reset cannot land it below the
|
||||
// 288px tight threshold the way a hardcoded 256 did.
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter', defaultWidth: 320 })).toBe(320);
|
||||
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -123,6 +127,61 @@ describe('tab rail resize wiring', () => {
|
||||
expect(app._tabRailResizeOwnsObserver).toBe(false);
|
||||
});
|
||||
|
||||
it('re-runs the wrap pass when the compact threshold flips, without double-rendering', () => {
|
||||
const controller = readPublic('tab-rail-resize.js');
|
||||
class FakeCodemanApp {}
|
||||
const classes = new Set<string>();
|
||||
const context = vm.createContext({
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: { CodemanTabRail: loadRailPolicy() },
|
||||
document: {
|
||||
documentElement: {
|
||||
style: { setProperty: () => {} },
|
||||
classList: {
|
||||
contains: (c: string) => classes.has(c),
|
||||
toggle: (c: string, force: boolean) => {
|
||||
if (force) classes.add(c);
|
||||
else classes.delete(c);
|
||||
return force;
|
||||
},
|
||||
},
|
||||
},
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
},
|
||||
console,
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
});
|
||||
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
|
||||
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
|
||||
app._getTabRailBounds = () => ({});
|
||||
app.syncTabRailWidthSetting = vi.fn();
|
||||
app._fullRenderSessionTabs = vi.fn();
|
||||
|
||||
// Rich rail dragged below 240px: applyTabWrapSettings() owns the folder
|
||||
// line and reads the compact class this call just toggled, so it must be
|
||||
// re-consulted on the flip — and when its own conditional render fires
|
||||
// (the folder flag changed), the explicit render must not double it.
|
||||
app._tallTabsEnabled = true;
|
||||
app.applyTabWrapSettings = vi.fn(() => {
|
||||
app._tallTabsEnabled = false;
|
||||
app._fullRenderSessionTabs();
|
||||
});
|
||||
app._setTabRailWidth(210);
|
||||
expect(app.applyTabWrapSettings).toHaveBeenCalledOnce();
|
||||
expect(app._fullRenderSessionTabs).toHaveBeenCalledOnce();
|
||||
|
||||
// Flip back up with an unchanged folder flag (simple-detail rail): the
|
||||
// explicit render must still fire — the compact row-action affordance
|
||||
// changed even though the wrap pass rendered nothing.
|
||||
app.applyTabWrapSettings = vi.fn();
|
||||
app._fullRenderSessionTabs = vi.fn();
|
||||
app._setTabRailWidth(300);
|
||||
expect(app.applyTabWrapSettings).toHaveBeenCalledOnce();
|
||||
expect(app._fullRenderSessionTabs).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = readPublic('tab-rail-resize.js');
|
||||
|
||||
Reference in New Issue
Block a user