diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 0c673528..256603e7 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -112,6 +112,9 @@ // Cleared on every open: a fresh socket must announce its size, which is // also what registers it as a desktop viewer server-side. this._lastSentDims = null; + // Whether the pointer is over a link in THIS pane (the primary pane's own + // flag, app._linkHovered, belongs to its terminal alone). + this._linkHovered = false; } async connect() { @@ -135,6 +138,17 @@ this.terminal.open(this.mountEl); this.fitAddon.fit(); + // File paths and URLs printed here are clickable, through the SAME + // provider as the primary pane (registerFilePathLinkProvider, + // terminal-ui.js), and open against THIS pane's session. + global.app?.registerFilePathLinkProvider?.({ + terminal: this.terminal, + getSessionId: () => this.sessionId, + setHovered: (hovered) => { + this._linkHovered = hovered; + }, + }); + this._installWheelListener(); this.terminal.onData((data) => this._onTerminalData(data)); @@ -148,9 +162,8 @@ // Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter // newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same // registry-aware predicates so a rebind or a disable restores plain - // terminal behavior here too. Ctrl+V is deliberately left on xterm's own - // default (plain-text paste): Pane B has no image-paste trap to route it - // to, so intercepting it here would only break paste. + // terminal behavior here too. Ctrl+V goes through the primary pane's + // paste trap (image-input.js), aimed at this pane (below). this.terminal.attachCustomKeyEventHandler((ev) => { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; if ( @@ -167,6 +180,15 @@ if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) { return false; } + // Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so + // a pasted image uploads to this pane's session and its path is typed + // here, and pasted text goes into this xterm with its bracketed-paste + // markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it + // xterm's default only ever pasted text. + if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') { + global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId }); + return false; + } // Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own // swallow — in a plain shell session this is the user's own // job-control tool and must reach the PTY, but in every other mode diff --git a/test/terminal-link-provider-target.test.ts b/test/terminal-link-provider-target.test.ts new file mode 100644 index 00000000..288d346f --- /dev/null +++ b/test/terminal-link-provider-target.test.ts @@ -0,0 +1,146 @@ +/** + * @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 = {}; + 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, + 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)(); +} + +/** 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'); + }); +}); diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index cfd99e14..cba31c62 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -90,7 +90,10 @@ class FakeTerminal { onData(cb: (data: string) => void) { this.dataCb = cb; } - attachCustomKeyEventHandler() {} + keyHandler: ((ev: Record) => boolean) | null = null; + attachCustomKeyEventHandler(fn: (ev: Record) => boolean) { + this.keyHandler = fn; + } registerLinkProvider() {} writes: string[] = []; write(data: string, cb?: () => void) { @@ -177,6 +180,8 @@ function makeApp(): App { app._updateConnectionIndicator = vi.fn(); app.markIdleAlertSeen = vi.fn(); app.showToast = vi.fn(); + // The key handler's chord gates read the shortcut registry, which reads these. + app.loadAppSettingsFromStorage = () => ({}); app._ws = null; app._wsSessionId = null; app._estimateReplayRows = (text: string) => text.split('\n').length; @@ -597,3 +602,41 @@ describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () expect(term.resizes.length).toBe(before); }); }); + +describe('TerminalTile links and paste follow THIS pane', () => { + it('registers the shared link provider on its own terminal, resolving its own session', async () => { + const app = makeApp(); + const register = vi.fn(); + app.registerFilePathLinkProvider = register; + + const { term } = await connectTile(app); + + expect(register).toHaveBeenCalledTimes(1); + const target = register.mock.calls[0][0] as { terminal: unknown; getSessionId: () => string }; + expect(target.terminal).toBe(term); + expect(target.getSessionId()).toBe('s-tile'); + }); + + it("routes Ctrl+V into the paste trap with this pane's terminal and session", async () => { + const app = makeApp(); + const paste = vi.fn(); + app._handleImagePaste = paste; + const { term } = await connectTile(app); + + const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' }); + + expect(handled).toBe(false); + expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' }); + }); + + it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => { + const app = makeApp(); + const paste = vi.fn(); + app._handleImagePaste = paste; + const { term } = await connectTile(app); + + term.keyHandler!({ type: 'keydown', key: 'V', ctrlKey: true, shiftKey: true, code: 'KeyV' }); + + expect(paste).not.toHaveBeenCalled(); + }); +});