mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
fix(tabs): grouped rail interaction fixes for inline rename
Two problems with renaming a tab in the vertical rail, both easier to hit now that the grouped rail has its own inline editor beside the session one. Writes. A committed rename PUT its name and only applied the answer if the same editor was still open when it came back. Reopening the editor before the PUT answered (F2 or right-click again, or starting a group rename, which cancels the session editor) threw the confirmed name away, so the tab kept showing the old name until an SSE frame happened to repaint it. Two quick renames also raced as two concurrent PUTs. Inline renames now go through a per-session queue: one PUT at a time in the order they were made, the confirmed name applied to app.sessions whatever happened to the editor, and the "already that name" check made when the write runs rather than when Enter is pressed, so confirming the name still on screen over a write in flight is a real write. Layout. The editor (a flex row) could not shrink below the input's intrinsic width, so a long w<n>-<case> prefix pushed the label past its row: the prefix slid out of view in the detailed rows and the input was clipped mid-word in the compact rail. The label now has min-width 0, the prefix gives way first (down to 2rem, with an ellipsis), the input keeps 4rem, and in the compact rail the row's adornments step aside while the name is edited. The detailed rows' three-line clamp also outranked the shared unclamp rule, which is what the existing "unclamped editor" browser test caught; it is restated there. Header strip, sidebar and flat-rail markup are unchanged. Tests (test/inline-rename.test.ts, browser suite): the unclamp check runs for simple and detailed rows; a write-ordering describe covers ordering, a reopened editor cancelled over a confirmed write, a re-sent unchanged name and a group rename taking over; a long-prefix describe drives real rows from a live session in simple, detailed and compact rails.
This commit is contained in:
+361
-40
@@ -13,7 +13,8 @@
|
||||
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
|
||||
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
|
||||
*
|
||||
* Port: 3164 (per MEMORY.md, ports 3150+ for tests)
|
||||
* Ports: 3164, plus 3165 and 3192 for the two server-backed describes below
|
||||
* (per MEMORY.md, ports 3150+ for tests)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
@@ -21,6 +22,8 @@ import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3164;
|
||||
const ORDERING_PORT = 3165;
|
||||
const LONG_PREFIX_PORT = 3192;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('Inline rename input', () => {
|
||||
@@ -581,54 +584,372 @@ describe('Inline rename input', () => {
|
||||
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
|
||||
await resetState();
|
||||
const id = 'vertical-live-input';
|
||||
// The rail has two row variants, and the detailed one clamps the name to
|
||||
// three lines instead of two: the editor must come out unclamped in both,
|
||||
// and cancelling must put back the clamp of the variant it was opened in.
|
||||
it.each([
|
||||
{ detail: 'simple', restoredClamp: '2' },
|
||||
{ detail: 'rich', restoredClamp: '3' },
|
||||
])(
|
||||
'Vertical rail ($detail rows) paints typing in an unclamped editor and restores the clamp on cancel',
|
||||
async ({ detail, restoredClamp }) => {
|
||||
await resetState();
|
||||
const id = `vertical-live-input-${detail}`;
|
||||
|
||||
await page.evaluate((sessionId) => {
|
||||
await page.evaluate(
|
||||
({ sessionId, detail }) => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
startInlineRename: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
document.documentElement.dataset.tabOrientation = 'vertical';
|
||||
document.documentElement.dataset.tabRailDetail = detail;
|
||||
const rail = document.getElementById('tabRail') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.innerHTML =
|
||||
`<span class="tab-name" data-session-id="${sessionId}">` +
|
||||
'<span class="tab-name-prefix">w9-case: </span>old</span>';
|
||||
rail.appendChild(tab);
|
||||
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
|
||||
app.startInlineRename(sessionId);
|
||||
},
|
||||
{ sessionId: id, detail }
|
||||
);
|
||||
|
||||
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
|
||||
const input = label.locator('input.tab-rename-input');
|
||||
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await page.keyboard.type('edited title');
|
||||
|
||||
expect(await input.inputValue()).toBe('edited title');
|
||||
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
|
||||
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
|
||||
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
|
||||
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
||||
|
||||
const settled = await page.evaluate((sessionId) => {
|
||||
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
||||
app._activeRename?.cancel();
|
||||
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||
const result = {
|
||||
classActive: label.classList.contains('tab-name-renaming'),
|
||||
inputPresent: !!label.querySelector('input.tab-rename-input'),
|
||||
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
|
||||
};
|
||||
document.documentElement.dataset.tabOrientation = 'horizontal';
|
||||
return result;
|
||||
}, id);
|
||||
|
||||
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: restoredClamp });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Two renames of one session can be in flight at once: commit, reopen the
|
||||
* editor before the PUT answers, then commit or cancel again (or start a group
|
||||
* rename, which cancels the session editor). The writes go out one at a time
|
||||
* in the order they were made, and a confirmed write is applied locally even
|
||||
* if the editor that made it has since been cancelled, so the tab never shows
|
||||
* a name the server no longer holds.
|
||||
*/
|
||||
describe('Inline rename write ordering', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
type Pending = { body: string; resolve: (response: Response) => void };
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(ORDERING_PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${ORDERING_PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof (window as { app?: unknown }).app !== 'undefined' &&
|
||||
!!(window as { app?: { sessions?: Map<string, unknown> } }).app?.sessions
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/** Mount a header-strip row for `id`, hold every PUT open, and open its editor. */
|
||||
async function mount(id: string, name: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ id, name }) => {
|
||||
const w = window as unknown as {
|
||||
app: {
|
||||
_activeRename: { cancel: () => void } | null;
|
||||
sessions: Map<string, { id: string; name: string; status: string }>;
|
||||
sessionOrder: string[];
|
||||
};
|
||||
__pending: Array<{ body: string; resolve: (response: Response) => void }>;
|
||||
__origFetch?: typeof window.fetch;
|
||||
};
|
||||
w.app._activeRename?.cancel();
|
||||
w.app.sessions.clear();
|
||||
document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove());
|
||||
w.app.sessions.set(id, { id, name, status: 'idle' });
|
||||
w.app.sessionOrder = [id];
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.dataset.id = id;
|
||||
tab.innerHTML = `<span class="tab-info"><span class="tab-name" data-session-id="${id}">${name}</span></span>`;
|
||||
(document.getElementById('sessionTabs') as HTMLElement).appendChild(tab);
|
||||
w.__pending = [];
|
||||
w.__origFetch ??= window.fetch;
|
||||
const passThrough = w.__origFetch;
|
||||
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method !== 'PUT' || !String(input).endsWith('/name')) return passThrough(input, init);
|
||||
return new Promise<Response>((resolve) => {
|
||||
w.__pending.push({ body: String(init?.body ?? ''), resolve });
|
||||
});
|
||||
}) as typeof window.fetch;
|
||||
},
|
||||
{ id, name }
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreFetch(): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __origFetch?: typeof window.fetch };
|
||||
if (w.__origFetch) window.fetch = w.__origFetch;
|
||||
});
|
||||
}
|
||||
|
||||
async function commit(id: string, value: string | null): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ id, value }) => {
|
||||
const app = (window as unknown as { app: { startInlineRename: (id: string) => void } }).app;
|
||||
if (!document.querySelector(`.tab-name[data-session-id="${id}"] input.tab-rename-input`)) {
|
||||
app.startInlineRename(id);
|
||||
}
|
||||
const input = document.querySelector(
|
||||
`.tab-name[data-session-id="${id}"] input.tab-rename-input`
|
||||
) as HTMLInputElement;
|
||||
if (value !== null) input.value = value;
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
},
|
||||
{ id, value }
|
||||
);
|
||||
}
|
||||
|
||||
async function answer(index: number, name: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ index, body }) => {
|
||||
const w = window as unknown as { __pending: Pending[] };
|
||||
w.__pending[index]?.resolve(
|
||||
new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
},
|
||||
{ index, body: JSON.stringify({ success: true, data: { name } }) }
|
||||
);
|
||||
}
|
||||
|
||||
async function state(id: string) {
|
||||
return page.evaluate((id) => {
|
||||
const w = window as unknown as {
|
||||
app: { sessions: Map<string, { name: string }>; _activeRename: unknown };
|
||||
__pending: Pending[];
|
||||
};
|
||||
return {
|
||||
bodies: w.__pending.map(({ body }) => JSON.parse(body).name),
|
||||
mapName: w.app.sessions.get(id)?.name ?? null,
|
||||
renameActive: !!w.app._activeRename,
|
||||
};
|
||||
}, id);
|
||||
}
|
||||
|
||||
it('sends successive renames of one session one at a time, in the order they were made', async () => {
|
||||
await mount('order', 'Old');
|
||||
await commit('order', 'First');
|
||||
await commit('order', 'Second');
|
||||
expect((await state('order')).bodies).toEqual(['First']);
|
||||
|
||||
await answer(0, 'First');
|
||||
expect((await state('order')).bodies).toEqual(['First', 'Second']);
|
||||
await answer(1, 'Second');
|
||||
await restoreFetch();
|
||||
expect(await state('order')).toEqual({ bodies: ['First', 'Second'], mapName: 'Second', renameActive: false });
|
||||
});
|
||||
|
||||
it('keeps a confirmed rename when the editor reopened over it is cancelled', async () => {
|
||||
await mount('reopen', 'Old');
|
||||
await commit('reopen', 'First');
|
||||
await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as { app: { startInlineRename: (id: string) => void; _activeRename: { cancel: () => void } } }
|
||||
).app;
|
||||
app.startInlineRename('reopen');
|
||||
app._activeRename.cancel();
|
||||
});
|
||||
await answer(0, 'First');
|
||||
await restoreFetch();
|
||||
expect(await state('reopen')).toEqual({ bodies: ['First'], mapName: 'First', renameActive: false });
|
||||
});
|
||||
|
||||
it('re-sends the shown name when it is confirmed unchanged over a rename still in flight', async () => {
|
||||
await mount('stale', 'Old');
|
||||
await commit('stale', 'First');
|
||||
// The reopened editor still shows "Old" (the PUT has not answered), and
|
||||
// Enter confirms that: the user's last word is "Old", not "First".
|
||||
await commit('stale', null);
|
||||
await answer(0, 'First');
|
||||
await answer(1, 'Old');
|
||||
await restoreFetch();
|
||||
expect(await state('stale')).toEqual({ bodies: ['First', 'Old'], mapName: 'Old', renameActive: false });
|
||||
});
|
||||
|
||||
it('keeps a confirmed session rename when a group rename takes over the editor', async () => {
|
||||
await mount('to-group', 'Old');
|
||||
await commit('to-group', 'Saved');
|
||||
const groupStarted = await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
startInlineRename: (id: string) => void;
|
||||
tabLayout: unknown;
|
||||
startTabGroupRename: (groupId: string) => boolean;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
document.documentElement.dataset.tabOrientation = 'vertical';
|
||||
const rail = document.getElementById('tabRail') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.innerHTML =
|
||||
`<span class="tab-name" data-session-id="${sessionId}">` +
|
||||
'<span class="tab-name-prefix">w9-case: </span>old</span>';
|
||||
rail.appendChild(tab);
|
||||
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
|
||||
app.startInlineRename(sessionId);
|
||||
}, id);
|
||||
const section = document.createElement('section');
|
||||
section.setAttribute('data-test-tab', '1');
|
||||
section.innerHTML =
|
||||
'<div class="tab-layout-group-header" data-tab-group-header="g1">' +
|
||||
'<span class="tab-layout-group-name">Group</span></div>';
|
||||
(document.getElementById('sessionTabs') as HTMLElement).appendChild(section);
|
||||
(window as unknown as { __origLayout: unknown }).__origLayout = app.tabLayout;
|
||||
app.tabLayout = { version: 1, groups: [{ id: 'g1', name: 'Group', refs: [] }], ungrouped: [] };
|
||||
return app.startTabGroupRename('g1');
|
||||
});
|
||||
expect(groupStarted).toBe(true);
|
||||
|
||||
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
|
||||
const input = label.locator('input.tab-rename-input');
|
||||
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await page.keyboard.type('edited title');
|
||||
|
||||
expect(await input.inputValue()).toBe('edited title');
|
||||
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
|
||||
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
|
||||
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
|
||||
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
|
||||
|
||||
const settled = await page.evaluate((sessionId) => {
|
||||
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
||||
app._activeRename?.cancel();
|
||||
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
|
||||
return {
|
||||
classActive: label.classList.contains('tab-name-renaming'),
|
||||
inputPresent: !!label.querySelector('input.tab-rename-input'),
|
||||
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
|
||||
await answer(0, 'Saved');
|
||||
const after = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { name: string }>;
|
||||
_inlineRenameActive: boolean;
|
||||
_activeRename: { cancel: () => void } | null;
|
||||
tabLayout: unknown;
|
||||
};
|
||||
__origLayout: unknown;
|
||||
};
|
||||
}, id);
|
||||
const groupInput = document.querySelector('.tab-layout-group-rename-input');
|
||||
const result = {
|
||||
mapName: w.app.sessions.get('to-group')?.name ?? null,
|
||||
groupEditorOpen: !!groupInput?.isConnected,
|
||||
guardHeld: w.app._inlineRenameActive,
|
||||
};
|
||||
w.app._activeRename?.cancel();
|
||||
w.app.tabLayout = w.__origLayout;
|
||||
return result;
|
||||
});
|
||||
await restoreFetch();
|
||||
expect(after).toEqual({ mapName: 'Saved', groupEditorOpen: true, guardHeld: true });
|
||||
});
|
||||
});
|
||||
|
||||
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
|
||||
/**
|
||||
* Real rows, rendered by the app from a live session: a long `w<n>-<case>`
|
||||
* prefix must not push the editor (or the prefix itself) out of the row in any
|
||||
* rail variant. The prefix gives way first, with an ellipsis, and the input
|
||||
* always keeps a usable width.
|
||||
*/
|
||||
describe('Vertical rail rename editor with a long prefix', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
const port = LONG_PREFIX_PORT;
|
||||
const NAME = 'w3-this_is_a_very_long_valid_prefix: charlie';
|
||||
let sessionId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(port, false, true);
|
||||
await server.start();
|
||||
const res = await fetch(`http://localhost:${port}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: NAME, mode: 'shell' }),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const created = (await res.json()) as { data?: { id?: string; session?: { id?: string } } };
|
||||
sessionId = created.data?.session?.id ?? created.data?.id ?? '';
|
||||
expect(sessionId).not.toBe('');
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it.each([
|
||||
{ variant: 'simple rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'simple' }, compact: false },
|
||||
{ variant: 'detailed rows', settings: { tabOrientation: 'vertical', tabRailDetail: 'rich' }, compact: false },
|
||||
{ variant: 'compact rail', settings: { tabOrientation: 'vertical', tabRailWidth: 208 }, compact: true },
|
||||
])('keeps the prefix and a usable input inside the row ($variant)', async ({ settings, compact }) => {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
|
||||
try {
|
||||
await context.addInitScript(
|
||||
(value) => localStorage.setItem('codeman-app-settings', JSON.stringify(value)),
|
||||
settings
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`http://localhost:${port}`, { waitUntil: 'domcontentloaded' });
|
||||
const row = page.locator(`#tabRail .session-tab[data-id="${sessionId}"]`);
|
||||
await row.waitFor({ state: 'visible', timeout: 15000 });
|
||||
expect(await page.evaluate(() => document.documentElement.classList.contains('tab-rail-compact'))).toBe(compact);
|
||||
|
||||
await row.click({ button: 'right' });
|
||||
const input = row.locator('input.tab-rename-input');
|
||||
await input.press('Control+A');
|
||||
await page.keyboard.type('typed live text');
|
||||
expect(await input.inputValue()).toBe('typed live text');
|
||||
|
||||
const geometry = await row.evaluate((tab) => {
|
||||
const box = (el: Element) => el.getBoundingClientRect();
|
||||
const within = (inner: DOMRect, outer: DOMRect) =>
|
||||
inner.left >= outer.left - 0.5 &&
|
||||
inner.right <= outer.right + 0.5 &&
|
||||
inner.top >= outer.top - 0.5 &&
|
||||
inner.bottom <= outer.bottom + 0.5;
|
||||
const input = tab.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||
const prefix = tab.querySelector('.tab-rename-prefix') as HTMLElement;
|
||||
const info = tab.querySelector('.tab-info') as HTMLElement;
|
||||
return {
|
||||
focused: document.activeElement === input,
|
||||
inputWidth: box(input).width,
|
||||
prefixWidth: box(prefix).width,
|
||||
inputInsideRow: within(box(input), box(info)),
|
||||
prefixInsideRow: within(box(prefix), box(info)),
|
||||
prefixEllipsis: getComputedStyle(prefix).textOverflow,
|
||||
};
|
||||
});
|
||||
expect(geometry.focused).toBe(true);
|
||||
expect(geometry.inputWidth).toBeGreaterThanOrEqual(64);
|
||||
expect(geometry.prefixWidth).toBeGreaterThanOrEqual(24);
|
||||
expect(geometry.inputInsideRow).toBe(true);
|
||||
expect(geometry.prefixInsideRow).toBe(true);
|
||||
expect(geometry.prefixEllipsis).toBe('ellipsis');
|
||||
|
||||
await input.press('Escape');
|
||||
expect(await row.locator('input.tab-rename-input').count()).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user