mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
xterm parses write() data after chunkedTerminalWrite has already cleared _isLoadingBuffer, so the main terminal's replay gate never held: a tab switch or reconnect re-copied the newest OSC 52 in the replayed history. Strip every OSC 52 from the replay beside the DEC 2026 markers (OSC52_STRIP_RE). Tiles keep their own _bufferLoading gate, which holds there. One Claude Code copy arrives up to three times (its own OSC 52, tmux load-buffer -w, set-clipboard forwarding), so identical text within OSC52_DEDUPE_MS is dropped. The manual-copy fallback opens only in the focused tab, so a background tab on the same session stays quiet. The replay tests now drive the real chunkedTerminalWrite into @xterm/headless and fail without the strip. Test harnesses that supply DEC_SYNC_STRIP_RE by hand get OSC52_STRIP_RE too, and CLAUDE.md records the rules next to Auto Copy.
148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
/**
|
|
* @fileoverview The file-path / URL link provider can serve a second terminal.
|
|
*
|
|
* `registerFilePathLinkProvider()` (terminal-ui.js) was hardwired to the
|
|
* primary terminal and the ACTIVE session: a path clicked anywhere opened
|
|
* against `activeSessionId`. It now takes an optional target `{ terminal,
|
|
* getSessionId, setHovered }`, so the split pane's second terminal (and later a
|
|
* grid tile) gets clickable paths that open against ITS session. With no target
|
|
* it behaves exactly as before, and only the primary registration is kept on
|
|
* `_terminalLinkProvider`, which the touch-tap path reads.
|
|
*
|
|
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
type Link = { text: string; activate: (ev: unknown, text: string) => void; hover: () => void; leave: () => void };
|
|
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
|
|
|
|
function loadHarness() {
|
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
|
const windowRef: Record<string, unknown> = {};
|
|
const context = vm.createContext({
|
|
window: windowRef,
|
|
document: {
|
|
body: { classList: { contains: () => false } },
|
|
activeElement: null,
|
|
addEventListener: vi.fn(),
|
|
getElementById: () => null,
|
|
},
|
|
CodemanApp,
|
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
|
_crashDiag: { log: vi.fn() },
|
|
performance: { now: () => 0 },
|
|
requestAnimationFrame: () => 1,
|
|
setTimeout: () => 1,
|
|
MobileDetection: { isTouchDevice: () => false },
|
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
|
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
|
});
|
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
|
return new (CodemanApp as unknown as new () => Record<string, any>)();
|
|
}
|
|
|
|
/** A one-screen xterm stand-in holding `lines`, recording every provider registered on it. */
|
|
function fakeTerminal(lines: string[]) {
|
|
const providers: Provider[] = [];
|
|
return {
|
|
cols: 80,
|
|
providers,
|
|
registerLinkProvider: (p: Provider) => providers.push(p),
|
|
buffer: {
|
|
active: {
|
|
length: lines.length,
|
|
getLine: (row: number) =>
|
|
row >= 0 && row < lines.length
|
|
? {
|
|
isWrapped: false,
|
|
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
|
|
}
|
|
: undefined,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function linksOn(provider: Provider, line: number): Link[] {
|
|
let out: Link[] = [];
|
|
provider.provideLinks(line, (links) => {
|
|
out = links || [];
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function makeApp() {
|
|
const app = loadHarness();
|
|
app.activeSessionId = 'primary-session';
|
|
app.openFilePreview = vi.fn();
|
|
app.openLogViewerWindow = vi.fn();
|
|
app._isExternalPreviewPath = () => false;
|
|
return app;
|
|
}
|
|
|
|
describe('registerFilePathLinkProvider with no target (the primary pane)', () => {
|
|
it('registers on the primary terminal, keeps the provider for the tap path, opens against the active session', () => {
|
|
const app = makeApp();
|
|
app.terminal = fakeTerminal(['wrote /tmp/shot.png']);
|
|
|
|
const provider = app.registerFilePathLinkProvider();
|
|
|
|
expect(app.terminal.providers).toEqual([provider]);
|
|
expect(app._terminalLinkProvider).toBe(provider);
|
|
const [link] = linksOn(provider, 1);
|
|
link.activate({}, link.text);
|
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/shot.png', 'primary-session');
|
|
link.hover();
|
|
expect(app._linkHovered).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('registerFilePathLinkProvider with a target (a second pane)', () => {
|
|
it("registers on the target terminal and opens against the target's session, not the active one", () => {
|
|
const app = makeApp();
|
|
app.terminal = fakeTerminal([]);
|
|
const primaryProvider = app.registerFilePathLinkProvider();
|
|
const paneTerminal = fakeTerminal(['tail -f /var/log/app.log']);
|
|
const setHovered = vi.fn();
|
|
|
|
const provider = app.registerFilePathLinkProvider({
|
|
terminal: paneTerminal,
|
|
getSessionId: () => 'pane-session',
|
|
setHovered,
|
|
});
|
|
|
|
expect(paneTerminal.providers).toEqual([provider]);
|
|
expect(app.terminal.providers).toEqual([primaryProvider]);
|
|
// The tap path's provider is still the primary one.
|
|
expect(app._terminalLinkProvider).toBe(primaryProvider);
|
|
|
|
const [link] = linksOn(provider, 1);
|
|
link.activate({}, link.text);
|
|
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'pane-session');
|
|
|
|
link.hover();
|
|
link.leave();
|
|
expect(setHovered.mock.calls).toEqual([[true], [false]]);
|
|
expect(app._linkHovered).toBeUndefined();
|
|
});
|
|
|
|
it('reads the session at click time, so a pane rebound to another session follows it', () => {
|
|
const app = makeApp();
|
|
let sessionId = 'first';
|
|
const paneTerminal = fakeTerminal(['see /tmp/a.pdf']);
|
|
const provider = app.registerFilePathLinkProvider({ terminal: paneTerminal, getSessionId: () => sessionId });
|
|
|
|
sessionId = 'second';
|
|
const [link] = linksOn(provider, 1);
|
|
link.activate({}, link.text);
|
|
|
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/a.pdf', 'second');
|
|
});
|
|
});
|