mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
230 lines
9.2 KiB
TypeScript
230 lines
9.2 KiB
TypeScript
/**
|
||||
|
|
* @fileoverview Each session in the split view names its harness and model.
|
|||
|
|
*
|
|||
|
|
* - Pane B's header is the tile header's strip: the harness logo (PR #532's
|
|||
|
|
* `run-mode-dot <cliId>` slot), the name and the model, and the close button
|
|||
|
|
* at a tile button's size.
|
|||
|
|
* - Pane A is the main terminal, which has no header of its own: while the
|
|||
|
|
* split is open it gets the same strip (minus the close), as the FIRST child
|
|||
|
|
* of `.terminal-wrap`, and loses it when the split closes. That strip takes
|
|||
|
|
* height from the main terminal, so opening fits it through sendResize (its
|
|||
|
|
* first step is syncTerminalGeometry, #464) with the strip already in place,
|
|||
|
|
* and closing gives the height back the same way, never with a bare
|
|||
|
|
* `fitAddon.fit()`.
|
|||
|
|
* - Every tab render refreshes both headers (a rename, a model switch, the
|
|||
|
|
* active session changing under Pane A); an unchanged session writes nothing.
|
|||
|
|
*
|
|||
|
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
|||
|
|
*/
|
|||
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
|
import {
|
|||
|
|
FakeEl,
|
|||
|
|
FakeTile,
|
|||
|
|
bySelector,
|
|||
|
|
main,
|
|||
|
|
makeGridApp,
|
|||
|
|
resetGridHarness,
|
|||
|
|
windowStub,
|
|||
|
|
wrap,
|
|||
|
|
type GridApp,
|
|||
|
|
} from './mocks/tile-grid-vm.js';
|
|||
|
|
|
|||
|
|
const IDS = ['s-a', 's-b', 's-c'];
|
|||
|
|
|
|||
|
|
beforeEach(() => {
|
|||
|
|
resetGridHarness();
|
|||
|
|
windowStub.__codemanCliCatalog = [
|
|||
|
|
{ id: 'claude', label: 'Claude Code' },
|
|||
|
|
{ id: 'deepseek', label: 'DeepSeek' },
|
|||
|
|
];
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** s-a (dsh on its route) in the main pane, s-b (claude) in Pane B. */
|
|||
|
|
function openSplit(): GridApp {
|
|||
|
|
const app = makeGridApp(IDS);
|
|||
|
|
Object.assign(app.sessions.get('s-a'), {
|
|||
|
|
name: 'review-api',
|
|||
|
|
mode: 'deepseek',
|
|||
|
|
displayModel: { model: 'qwen3.8-27b', source: 'screen' },
|
|||
|
|
});
|
|||
|
|
Object.assign(app.sessions.get('s-b'), {
|
|||
|
|
name: 'docs-pass',
|
|||
|
|
displayModel: { model: 'Haiku 4.5', source: 'statusline' },
|
|||
|
|
});
|
|||
|
|
app.syncTerminalGeometry = vi.fn();
|
|||
|
|
app.fitAddon = { fit: vi.fn() };
|
|||
|
|
app.openSplitPane('s-b');
|
|||
|
|
// closeSplitPane finds its container by selector.
|
|||
|
|
const container = main.querySelector('.terminal-split-container');
|
|||
|
|
if (container) bySelector.set('.terminal-split-container', container);
|
|||
|
|
return app;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const headerB = () => main.querySelector('.terminal-pane-b')!.querySelector('.terminal-pane-b-header') as FakeEl;
|
|||
|
|
const headerA = () => wrap.querySelector('.terminal-pane-a-header');
|
|||
|
|
const part = (header: FakeEl, cls: string) => header.querySelector(`.${cls}`) as FakeEl;
|
|||
|
|
const modelName = (header: FakeEl) => part(header, 'split-model').children[0];
|
|||
|
|
/** A tab render, through the real wrapper chain (the original returns early mid-rename). */
|
|||
|
|
function renderTabs(app: GridApp) {
|
|||
|
|
app._inlineRenameActive = true;
|
|||
|
|
app._renderSessionTabsImmediate();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('the split headers', () => {
|
|||
|
|
it("Pane B's header: logo, name, model, and the close button at a tile button's size", () => {
|
|||
|
|
openSplit();
|
|||
|
|
const b = headerB();
|
|||
|
|
expect(b.children.map((c) => c.className.split(' ')[0])).toEqual(['split-harness', 'split-title', 'tile-btn']);
|
|||
|
|
expect(part(b, 'split-harness').className).toBe('split-harness run-mode-dot claude');
|
|||
|
|
expect(part(b, 'session-name').textContent).toBe('docs-pass');
|
|||
|
|
expect(modelName(b).textContent).toBe('Haiku 4.5');
|
|||
|
|
expect(part(b, 'split-harness').title).toBe('Claude Code · Haiku 4.5');
|
|||
|
|
expect(part(b, 'split-harness').getAttribute('aria-label')).toBe('Claude Code · Haiku 4.5');
|
|||
|
|
const close = part(b, 'terminal-pane-b-close');
|
|||
|
|
expect(close.className).toBe('tile-btn tile-remove terminal-pane-b-close');
|
|||
|
|
expect(close.getAttribute('aria-label')).toBe('Close split');
|
|||
|
|
expect(close.textContent).toBe('×');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("Pane A gets the same strip, first in the main terminal's wrap, without a close", () => {
|
|||
|
|
openSplit();
|
|||
|
|
const a = headerA()!;
|
|||
|
|
expect(wrap.children[0]).toBe(a);
|
|||
|
|
expect(a.className).toBe('terminal-pane-b-header terminal-pane-a-header');
|
|||
|
|
expect(part(a, 'split-harness').className).toBe('split-harness run-mode-dot deepseek');
|
|||
|
|
expect(part(a, 'session-name').textContent).toBe('review-api');
|
|||
|
|
expect(modelName(a).textContent).toBe('qwen3.8-27b');
|
|||
|
|
expect(part(a, 'split-harness').title).toBe('DeepSeek · qwen3.8-27b');
|
|||
|
|
expect(part(a, 'terminal-pane-b-close')).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('names and models are text, and the model is never translated', () => {
|
|||
|
|
const app = makeGridApp(IDS);
|
|||
|
|
app.sessions.get('s-b').name = '<img src=x onerror=alert(1)>';
|
|||
|
|
app.sessions.get('s-b').displayModel = { model: '<b>m</b>', source: 'statusline' };
|
|||
|
|
app.syncTerminalGeometry = vi.fn();
|
|||
|
|
app.openSplitPane('s-b');
|
|||
|
|
expect(part(headerB(), 'session-name').textContent).toBe('<img src=x onerror=alert(1)>');
|
|||
|
|
expect(part(headerB(), 'session-name').children).toHaveLength(0);
|
|||
|
|
expect(modelName(headerB()).textContent).toBe('<b>m</b>');
|
|||
|
|
expect(modelName(headerB()).getAttribute('data-i18n-skip')).toBe('');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('an unknown model shows the logo alone', () => {
|
|||
|
|
const app = openSplit();
|
|||
|
|
delete app.sessions.get('s-b').displayModel;
|
|||
|
|
renderTabs(app);
|
|||
|
|
expect(part(headerB(), 'split-model').hidden).toBe(true);
|
|||
|
|
expect(modelName(headerB()).textContent).toBe('');
|
|||
|
|
expect(part(headerB(), 'split-harness').title).toBe('Claude Code');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('a tab render carries a rename and a model switch into both headers', () => {
|
|||
|
|
const app = openSplit();
|
|||
|
|
app.sessions.set('s-b', {
|
|||
|
|
...app.sessions.get('s-b'),
|
|||
|
|
name: 'docs-pass-2',
|
|||
|
|
displayModel: { model: 'Sonnet 4.6', source: 'statusline' },
|
|||
|
|
});
|
|||
|
|
app.sessions.get('s-a').displayModel = { model: 'deepseek-v4-flash', source: 'screen' };
|
|||
|
|
renderTabs(app);
|
|||
|
|
expect(part(headerB(), 'session-name').textContent).toBe('docs-pass-2');
|
|||
|
|
expect(modelName(headerB()).textContent).toBe('Sonnet 4.6');
|
|||
|
|
expect(modelName(headerA()!).textContent).toBe('deepseek-v4-flash');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("Pane A's header follows the active session", () => {
|
|||
|
|
const app = openSplit();
|
|||
|
|
app.activeSessionId = 's-c';
|
|||
|
|
renderTabs(app);
|
|||
|
|
expect(part(headerA()!, 'session-name').textContent).toBe('s-c');
|
|||
|
|
expect(part(headerA()!, 'split-harness').className).toBe('split-harness run-mode-dot claude');
|
|||
|
|
expect(part(headerA()!, 'split-model').hidden).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('an unchanged session writes nothing on a refresh', () => {
|
|||
|
|
const app = openSplit();
|
|||
|
|
const writes: string[] = [];
|
|||
|
|
for (const [which, header] of [
|
|||
|
|
['a', headerA()!],
|
|||
|
|
['b', headerB()],
|
|||
|
|
] as Array<[string, FakeEl]>) {
|
|||
|
|
for (const [node, props] of [
|
|||
|
|
[part(header, 'split-harness'), ['className', 'title']],
|
|||
|
|
[part(header, 'session-name'), ['textContent']],
|
|||
|
|
[part(header, 'split-model'), ['title', 'hidden']],
|
|||
|
|
[modelName(header), ['textContent']],
|
|||
|
|
] as Array<[FakeEl, string[]]>) {
|
|||
|
|
for (const prop of props) {
|
|||
|
|
let value = (node as unknown as Record<string, unknown>)[prop];
|
|||
|
|
Object.defineProperty(node, prop, {
|
|||
|
|
get: () => value,
|
|||
|
|
set: (v) => {
|
|||
|
|
writes.push(`${which} ${prop}`);
|
|||
|
|
value = v;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const setAttribute = node.setAttribute.bind(node);
|
|||
|
|
node.setAttribute = (k: string, v: string) => {
|
|||
|
|
writes.push(`${which} @${k}`);
|
|||
|
|
setAttribute(k, v);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
renderTabs(app);
|
|||
|
|
renderTabs(app);
|
|||
|
|
expect(writes).toEqual([]);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("the main terminal's height", () => {
|
|||
|
|
it('opening fits Pane A with its header strip already in place, through sendResize', () => {
|
|||
|
|
const app = makeGridApp(IDS);
|
|||
|
|
app.fitAddon = { fit: vi.fn() };
|
|||
|
|
let stripInPlace = false;
|
|||
|
|
app.sendResize = vi.fn(() => {
|
|||
|
|
stripInPlace = wrap.children[0]?.classList.contains('terminal-pane-a-header') ?? false;
|
|||
|
|
return Promise.resolve(true);
|
|||
|
|
});
|
|||
|
|
app.openSplitPane('s-b');
|
|||
|
|
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
|
|||
|
|
expect(stripInPlace).toBe(true);
|
|||
|
|
expect(app.fitAddon.fit).not.toHaveBeenCalled();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('closing takes the strip away and gives the height back through sendResize, never a bare fit', () => {
|
|||
|
|
const app = openSplit();
|
|||
|
|
app.sendResize.mockClear();
|
|||
|
|
let stripGone = false;
|
|||
|
|
app.sendResize = vi.fn(() => {
|
|||
|
|
stripGone = headerA() === null;
|
|||
|
|
return Promise.resolve(true);
|
|||
|
|
});
|
|||
|
|
app.closeSplitPane();
|
|||
|
|
expect(headerA()).toBeNull();
|
|||
|
|
expect(app.sendResize).toHaveBeenCalledWith('s-a', { force: true });
|
|||
|
|
expect(stripGone).toBe(true);
|
|||
|
|
expect(app.fitAddon.fit).not.toHaveBeenCalled();
|
|||
|
|
expect(app._splitHeaders).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('a close that skips the resize (Pane A ended) still refits through syncTerminalGeometry', () => {
|
|||
|
|
const app = openSplit();
|
|||
|
|
app.sendResize.mockClear();
|
|||
|
|
app.closeSplitPane({ skipPrimaryResize: true });
|
|||
|
|
expect(headerA()).toBeNull();
|
|||
|
|
expect(app.sendResize).not.toHaveBeenCalled();
|
|||
|
|
expect(app.syncTerminalGeometry).toHaveBeenCalledTimes(1);
|
|||
|
|
expect(app.fitAddon.fit).not.toHaveBeenCalled();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("Pane B's close button closes the split", () => {
|
|||
|
|
const app = openSplit();
|
|||
|
|
const tile = FakeTile.all.at(-1) as FakeTile;
|
|||
|
|
part(headerB(), 'terminal-pane-b-close').dispatch('click');
|
|||
|
|
expect(app._splitPane).toBeNull();
|
|||
|
|
expect(tile.destroy).toHaveBeenCalledTimes(1);
|
|||
|
|
expect(headerA()).toBeNull();
|
|||
|
|
});
|
|||
|
|
});
|