diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 5aa8b5766..98a111a50 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -28,6 +28,7 @@ const runtimeEnvironmentCallMock = vi.fn() const runtimeEnvironmentTransportCallMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() +const setPendingEditorRevealMock = vi.fn() const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' } const storeState = { @@ -36,7 +37,8 @@ const storeState = { | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock, - openFile: openFileMock + openFile: openFileMock, + setPendingEditorReveal: setPendingEditorRevealMock } vi.mock('@/store', () => ({ @@ -65,6 +67,23 @@ function setPlatform(userAgent: string): void { vi.stubGlobal('navigator', { userAgent }) } +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function flushDoubleRaf(): Promise { + await flushAsyncWork() + await flushAsyncWork() +} + beforeEach(() => { clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() @@ -76,6 +95,7 @@ beforeEach(() => { storeState.settings = undefined registerHttpLinkStoreAccessor(() => storeState) vi.stubGlobal('window', { + dispatchEvent: vi.fn(), api: { shell: { openUrl: openUrlMock, @@ -90,6 +110,9 @@ beforeEach(() => { runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock } } }) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => { + return setTimeout(() => callback(0), 0) as unknown as number + }) }) afterEach(() => { @@ -191,6 +214,7 @@ describe('handleOscLink', () => { // Why: .html should not open Monaco — it should render in the browser tab. expect(openFileMock).not.toHaveBeenCalled() + expect(setPendingEditorRevealMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith( 'wt-1', 'file:///tmp/report.html', @@ -205,6 +229,7 @@ describe('handleOscLink', () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(openFileMock).not.toHaveBeenCalled() + expect(setPendingEditorRevealMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith( 'wt-1', 'file:///tmp/legacy.HTM', @@ -212,6 +237,41 @@ describe('handleOscLink', () => { ) }) + it('schedules Monaco reveal with default column 1 for :line links', async () => { + setPlatform('Macintosh') + + openDetectedFilePath('/tmp/src/main.ts', 42, null, deps) + await flushAsyncWork() + await flushDoubleRaf() + + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/src/main.ts' }) + ) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { + filePath: '/tmp/src/main.ts', + line: 42, + column: 1, + matchLength: 0 + }) + }) + + it('preserves explicit column for :line:column links', async () => { + setPlatform('Macintosh') + + openDetectedFilePath('/tmp/src/main.ts', 42, 7, deps) + await flushAsyncWork() + await flushDoubleRaf() + + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { + filePath: '/tmp/src/main.ts', + line: 42, + column: 7, + matchLength: 0 + }) + }) + it('advertises the browser-open behavior in the html hover hint', () => { setPlatform('Macintosh') expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open in browser') @@ -386,6 +446,39 @@ describe('handleOscLink', () => { expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() }) + + it('ignores stale async completion so latest click wins for open and reveal', async () => { + setPlatform('Macintosh') + const firstStat = createDeferred<{ isDirectory: boolean }>() + const secondStat = createDeferred<{ isDirectory: boolean }>() + statMock + .mockImplementationOnce(() => firstStat.promise) + .mockImplementationOnce(() => secondStat.promise) + + openDetectedFilePath('/tmp/src/first.ts', 10, 2, deps) + openDetectedFilePath('/tmp/src/second.ts', 20, 3, deps) + + secondStat.resolve({ isDirectory: false }) + await flushAsyncWork() + await flushDoubleRaf() + + firstStat.resolve({ isDirectory: false }) + await flushAsyncWork() + await flushDoubleRaf() + + expect(openFileMock).toHaveBeenCalledTimes(1) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/src/second.ts' }) + ) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null) + expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, { + filePath: '/tmp/src/second.ts', + line: 20, + column: 3, + matchLength: 0 + }) + expect(setPendingEditorRevealMock).toHaveBeenCalledTimes(2) + }) }) describe('createFilePathLinkProvider range bounds', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 582fac1e1..0e40e98f8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -87,6 +87,8 @@ function getTerminalFileContext( } } +let latestOpenDetectedFilePathRequestId = 0 + export function openDetectedFilePath( filePath: string, line: number | null, @@ -94,6 +96,7 @@ export function openDetectedFilePath( deps: Pick ): void { const { runtimeEnvironmentId, worktreeId, worktreePath } = deps + const requestId = ++latestOpenDetectedFilePathRequestId void (async () => { let statResult @@ -109,6 +112,10 @@ export function openDetectedFilePath( return } + if (requestId !== latestOpenDetectedFilePathRequestId) { + return + } + if (statResult.isDirectory) { const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) if (fileContext.connectionId || isRemoteRuntimeFileOperation(fileContext, filePath)) { @@ -158,11 +165,22 @@ export function openDetectedFilePath( }) if (line !== null) { + const targetColumn = column ?? 1 + store.setPendingEditorReveal(null) requestAnimationFrame(() => { requestAnimationFrame(() => { + if (requestId !== latestOpenDetectedFilePathRequestId) { + return + } + store.setPendingEditorReveal({ + filePath, + line, + column: targetColumn, + matchLength: 0 + }) window.dispatchEvent( new CustomEvent('orca:editor-reveal-location', { - detail: { filePath, line, column } + detail: { filePath, line, column: targetColumn } }) ) })