diff --git a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx index 2da4f0a97e8..8622cedf6a0 100644 --- a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx +++ b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx @@ -37,6 +37,8 @@ vi.mock('@/hooks/useModalReturnFocus', () => ({ useModalReturnFocus: () => ({ captureReturnFocus: vi.fn(), skipReturnFocus: vi.fn() }) })) +vi.mock('./quick-open-file-navigation', () => ({ openQuickOpenFile: vi.fn() })) + vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx index 24bf097a699..21508110322 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx @@ -220,13 +220,24 @@ export function BrowserPageToolbar({ } : undefined } - viewSource={{ - onSelect: () => void window.api.browser.openDevTools({ browserPageId }), - label: translate( - 'auto.components.browser.pane.BrowserPane.ec75d0c412', - 'Open browser devtools' - ) - }} + viewSource={ + openSourceFile + ? { + onSelect: openSourceFile, + label: translate( + 'auto.components.editor.HtmlDocPreview.openSourceControl', + 'Open source file' + ), + alreadyInOverflowMenu: true + } + : { + onSelect: () => void window.api.browser.openDevTools({ browserPageId }), + label: translate( + 'auto.components.browser.pane.BrowserPane.ec75d0c412', + 'Open browser devtools' + ) + } + } openExternal={{ onSelect: () => { if (!externalUrl) { diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts index fc345a8ee1b..401151166c2 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview.ts @@ -6,6 +6,7 @@ import { replacePersistentWebview, webviewRegistry } from './webview-registry' +import { recordLocalHtmlPreviewNavigation } from '../navigate/local-html-preview-freshness' export function setBrowserPageWebviewInputLock( webview: Electron.WebviewTag, @@ -70,6 +71,9 @@ export function ensureBrowserPageWebview({ setBrowserPageWebviewInputLock(webview, inputLocked) webview.style.background = 'var(--background)' const guest = webview + guest.addEventListener('did-start-navigation', (event) => { + recordLocalHtmlPreviewNavigation(guest, event) + }) // A committed synthetic blank document belongs to New Tab, including while its first URL waits. guest.addEventListener('load-commit', (event) => { if (event.isMainFrame) { diff --git a/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts index f1993e55fef..c499035fcc2 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.test.ts @@ -7,6 +7,9 @@ vi.mock('@/lib/connection-owner-resolution', async (importOriginal) => ({ getConnectionIdFromState: () => null, getConnectionIdForFileFromState: () => null })) +vi.mock('@/lib/user-opened-local-path', () => ({ + statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: false }) +})) import { useAppStore } from '@/store' import { editorTabFileAccess } from '@/lib/local-file-access' @@ -18,13 +21,13 @@ import { const PATH = '/Users/me/orca/chat-scratch/0123456789abcdef0123456789abcdef/usage.html' -it("opens a local chat visual's page as an editable source tab, read and saved in place", () => { +it("opens a local chat visual's page as an editable source tab, read and saved in place", async () => { const openFile = vi.fn() useAppStore.setState({ openFile }) expect(getShareableBrowserArtifactFile(`file://${encodeURI(PATH)}`)?.filePath).toBe(PATH) expect(canOpenBrowserFileInWorkspaceEditor(useAppStore.getState(), 'wt-1')).toBe(true) - openBrowserFileSource('wt-1', PATH) + await openBrowserFileSource('wt-1', PATH) const opened = openFile.mock.calls[0][0] expect(opened).toMatchObject({ filePath: PATH, relativePath: PATH, mode: 'edit' }) // What its editor reads and saves with: the user-named file itself, outside any project. diff --git a/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts index b4885047e55..85338ef0d09 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts +++ b/src/renderer/src/components/browser-pane/navigate/browser-file-editor-open.ts @@ -1,6 +1,8 @@ import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target' import { getRelativePathInsideRoot } from '@/lib/path' +import { statUserOpenedPath } from '@/lib/user-opened-local-path' +import { toast } from 'sonner' import type { AppState } from '@/store' import { useAppStore } from '@/store' import { openDocPreviewSource } from '../workspace-doc/doc-preview-document-actions' @@ -18,13 +20,33 @@ export function canOpenBrowserFileInWorkspaceEditor(state: AppState, worktreeId: } /** Opens the file behind a local browser page as its editable source, as a document preview does. */ -export function openBrowserFileSource(worktreeId: string, filePath: string): void { - const worktreeRoot = useAppStore.getState().getKnownWorktreeById(worktreeId)?.path ?? null - openDocPreviewSource({ - filePath, - relativePath: getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath, - worktreeId, - runtimeEnvironmentId: null, - externalSshTargetId: null - }) +export async function openBrowserFileSource(worktreeId: string, filePath: string): Promise { + const state = useAppStore.getState() + if (!canOpenBrowserFileInWorkspaceEditor(state, worktreeId)) { + return + } + const worktreeRoot = state.getKnownWorktreeById(worktreeId)?.path ?? null + try { + const stat = await statUserOpenedPath( + { target: { kind: 'local' }, worktreeId, worktreePath: worktreeRoot }, + filePath + ) + if ( + stat.isDirectory || + !canOpenBrowserFileInWorkspaceEditor(useAppStore.getState(), worktreeId) + ) { + return + } + openDocPreviewSource({ + filePath, + relativePath: stat.escapesWorktree + ? filePath + : (getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath), + worktreeId, + runtimeEnvironmentId: null, + externalSshTargetId: null + }) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } } diff --git a/src/renderer/src/components/browser-pane/navigate/local-html-preview-freshness.test.ts b/src/renderer/src/components/browser-pane/navigate/local-html-preview-freshness.test.ts new file mode 100644 index 00000000000..7916ab4306b --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/local-html-preview-freshness.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + recordLocalHtmlPreviewNavigation, + refreshLocalHtmlPreview +} from './local-html-preview-freshness' + +const stat = vi.fn() + +afterEach(() => vi.unstubAllGlobals()) + +beforeEach(() => { + stat.mockReset() + stat.mockResolvedValue({ size: 100, mtime: 1, isDirectory: false }) + vi.stubGlobal('window', { api: { fs: { stat } } }) +}) + +function guest() { + const reloadIgnoringCache = vi.fn() + const webview = { + getWebContentsId: () => 1, + reloadIgnoringCache + } + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tests use only the guest liveness and reload methods. + webview: webview as unknown as Electron.WebviewTag, + reloadIgnoringCache + } +} + +function navigate(webview: Electron.WebviewTag, url = 'file:///repo/report.html') { + recordLocalHtmlPreviewNavigation(webview, { isMainFrame: true, isInPlace: false, url }) +} + +it('preserves an unchanged page across repeated file opens and in-page navigation', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview) + recordLocalHtmlPreviewNavigation(webview, { + isMainFrame: true, + isInPlace: true, + url: 'file:///repo/report.html#section' + }) + + await refreshLocalHtmlPreview(webview, '/repo/report.html') + await refreshLocalHtmlPreview(webview, '/repo/report.html') + + expect(reloadIgnoringCache).not.toHaveBeenCalled() +}) + +it.each([ + { size: 100, mtime: 2 }, + { size: 120, mtime: 1 } +])('reloads changed disk content once, including regeneration: %j', async (revision) => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview) + stat.mockResolvedValue({ ...revision, isDirectory: false }) + + await Promise.all([ + refreshLocalHtmlPreview(webview, '/repo/report.html'), + refreshLocalHtmlPreview(webview, '/repo/report.html') + ]) + await refreshLocalHtmlPreview(webview, '/repo/report.html') + + expect(reloadIgnoringCache).toHaveBeenCalledTimes(1) +}) + +it('records the new disk revision after a manual reload', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview) + stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false }) + navigate(webview) + + await refreshLocalHtmlPreview(webview, '/repo/report.html') + + expect(reloadIgnoringCache).not.toHaveBeenCalled() +}) + +it('does not reload another page after navigation during the disk check', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview) + let finishStat = (_value: { size: number; mtime: number; isDirectory: boolean }): void => { + throw new Error('Disk check did not start') + } + stat.mockImplementationOnce( + () => + new Promise((resolve) => { + finishStat = resolve + }) + ) + const refresh = refreshLocalHtmlPreview(webview, '/repo/report.html') + navigate(webview, 'https://example.com') + finishStat({ size: 100, mtime: 2, isDirectory: false }) + await refresh + + expect(reloadIgnoringCache).not.toHaveBeenCalled() +}) + +it('keeps the loaded revision after a failed stat so a later successful open can refresh', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview) + stat.mockRejectedValueOnce(new Error('Filesystem unavailable')) + await refreshLocalHtmlPreview(webview, '/repo/report.html') + expect(reloadIgnoringCache).not.toHaveBeenCalled() + + stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false }) + await refreshLocalHtmlPreview(webview, '/repo/report.html') + expect(reloadIgnoringCache).toHaveBeenCalledOnce() +}) + +it('refreshes when the initial disk revision was unavailable but a later reopen can read it', async () => { + const { webview, reloadIgnoringCache } = guest() + stat.mockRejectedValueOnce(new Error('Filesystem unavailable')) + navigate(webview) + + await refreshLocalHtmlPreview(webview, '/repo/report.html') + await refreshLocalHtmlPreview(webview, '/repo/report.html') + + expect(reloadIgnoringCache).toHaveBeenCalledOnce() +}) + +it('refreshes a directly linked file through explicit access and retains its revision across reloads', async () => { + const { webview, reloadIgnoringCache } = guest() + let mtime = 1 + stat.mockImplementation(async ({ access }: { access: { kind: string } }) => { + if (access.kind === 'document-resource') { + throw new Error('Symlink target outside the document folder') + } + return { size: 100, mtime, isDirectory: false } + }) + reloadIgnoringCache.mockImplementation(() => navigate(webview)) + navigate(webview) + + await refreshLocalHtmlPreview(webview, '/repo/report.html') + await refreshLocalHtmlPreview(webview, '/repo/report.html') + expect(reloadIgnoringCache).toHaveBeenCalledTimes(1) + + mtime = 2 + await refreshLocalHtmlPreview(webview, '/repo/report.html') + await refreshLocalHtmlPreview(webview, '/repo/report.html') + expect(reloadIgnoringCache).toHaveBeenCalledTimes(2) +}) + +it('retains a changed revision for retry when the guest is temporarily missing', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview) + stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false }) + vi.spyOn(webview, 'getWebContentsId').mockImplementationOnce(() => { + throw new Error('Guest missing') + }) + + await refreshLocalHtmlPreview(webview, '/repo/report.html') + expect(reloadIgnoringCache).not.toHaveBeenCalled() + await refreshLocalHtmlPreview(webview, '/repo/report.html') + expect(reloadIgnoringCache).toHaveBeenCalledOnce() +}) + +it('retries a changed file after its guest was not ready to reload', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview) + stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false }) + reloadIgnoringCache.mockImplementationOnce(() => { + throw new Error('Guest not ready') + }) + + await refreshLocalHtmlPreview(webview, '/repo/report.html') + await refreshLocalHtmlPreview(webview, '/repo/report.html') + + expect(reloadIgnoringCache).toHaveBeenCalledTimes(2) +}) + +it('compares Windows file paths with encoded characters and fragments', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview, 'file:///C:/repo/report%20one.html#section') + stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false }) + + await refreshLocalHtmlPreview(webview, 'C:\\repo\\report one.html') + + expect(reloadIgnoringCache).toHaveBeenCalledOnce() +}) + +it('does not stat ordinary web pages or reload a guest without a loaded-file revision', async () => { + const { webview, reloadIgnoringCache } = guest() + navigate(webview, 'https://example.com/report.html') + + await refreshLocalHtmlPreview(webview, '/repo/report.html') + + expect(stat).not.toHaveBeenCalled() + expect(reloadIgnoringCache).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/browser-pane/navigate/local-html-preview-freshness.ts b/src/renderer/src/components/browser-pane/navigate/local-html-preview-freshness.ts new file mode 100644 index 00000000000..41e7f9c8421 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/local-html-preview-freshness.ts @@ -0,0 +1,85 @@ +import { documentResourceAccess, userNamedFileAccess } from '@/lib/local-file-access' +import { normalizeRuntimePathForComparison } from '../../../../../shared/cross-platform-path' +import { isHtmlFilePath } from '../../../../../shared/html-file-url' +import type { LocalFileAccess } from '../../../../../shared/local-file-access' +import { browserFileUrlToAbsolutePath } from '../describe-page/browser-artifact-upload' +import { reloadBrowserPageWebview } from './browser-reload-action' + +type FileRevision = { size: number; mtime: number } +type LoadedFile = { filePath: string; revision: Promise } + +const loadedFiles = new WeakMap() + +async function readFileRevision( + filePath: string, + access: LocalFileAccess = documentResourceAccess(filePath) +): Promise { + try { + const stat = await window.api.fs.stat({ + filePath, + access + }) + return stat.isDirectory ? null : { size: stat.size, mtime: stat.mtime } + } catch { + return null + } +} + +export function recordLocalHtmlPreviewNavigation( + webview: Electron.WebviewTag, + event: Pick +): void { + if (!event.isMainFrame || event.isInPlace) { + return + } + const filePath = browserFileUrlToAbsolutePath(event.url) + if (!filePath || !isHtmlFilePath(filePath)) { + loadedFiles.delete(webview) + return + } + const previous = loadedFiles.get(webview) + const previousRevision = + previous && + normalizeRuntimePathForComparison(previous.filePath) === + normalizeRuntimePathForComparison(filePath) + ? previous.revision + : null + loadedFiles.set(webview, { + filePath, + revision: readFileRevision(filePath).then((revision) => revision ?? previousRevision) + }) +} + +/** Explicit file opens refresh changed disk contents while unchanged pages keep their UI state. */ +export async function refreshLocalHtmlPreview( + webview: Electron.WebviewTag, + filePath: string +): Promise { + const loaded = loadedFiles.get(webview) + if ( + !loaded || + normalizeRuntimePathForComparison(loaded.filePath) !== + normalizeRuntimePathForComparison(filePath) + ) { + return + } + const [previous, current] = await Promise.all([ + loaded.revision, + readFileRevision(filePath, userNamedFileAccess()) + ]) + if ( + loadedFiles.get(webview) !== loaded || + !current || + (previous && previous.size === current.size && previous.mtime === current.mtime) + ) { + return + } + const refreshed = { filePath, revision: Promise.resolve(current) } + loadedFiles.set(webview, refreshed) + if ( + reloadBrowserPageWebview(webview, { ignoreCache: true }) !== 'reloaded' && + loadedFiles.get(webview) === refreshed + ) { + loadedFiles.set(webview, loaded) + } +} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts index 498d8fbb28e..66526126f0b 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as I18nModule from '@/i18n/i18n' const mocks = vi.hoisted(() => ({ /** What the per-file resolver answers: null local, a string remote, undefined unresolved. */ @@ -17,7 +18,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) -vi.mock('@/i18n/i18n', () => ({ +vi.mock('@/i18n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), translate: (_key: string, fallback: string, params?: Record) => fallback.replace('{{value0}}', params?.value0 ?? '') })) @@ -103,7 +105,8 @@ describe('openDocPreviewSource', () => { worktreeId: 'wt-1', language: 'html', mode: 'edit' - }) + }), + undefined ) }) @@ -113,7 +116,8 @@ describe('openDocPreviewSource', () => { openDocPreviewSource({ ...REMOTE_DOCUMENT, externalSshTargetId: 'ssh-7' }) expect(mocks.openFile).toHaveBeenCalledWith( - expect.objectContaining({ externalSshTargetId: 'ssh-7' }) + expect.objectContaining({ externalSshTargetId: 'ssh-7' }), + undefined ) }) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts index 7fd8309f01e..9208e9619b9 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts @@ -10,6 +10,7 @@ import { canClientOsOpenWorkspaceFile } from '@/lib/workspace-file-host-routing' import { useAppStore } from '@/store' +import { openWorkspaceFile } from '@/lib/workspace-file-open' import { downloadAndOpenRemoteTerminalFile } from '@/components/terminal-pane/terminal-remote-file-download-open' export type DocPreviewDocument = { @@ -30,16 +31,22 @@ export type DocPreviewDocument = { * preview keeps its own id, so the reader can leave the rendered page where it was. */ export function openDocPreviewSource(document: DocPreviewDocument): void { - useAppStore.getState().openFile({ - filePath: document.filePath, - relativePath: document.relativePath, - worktreeId: document.worktreeId, - // Why not the preview tab's language: it is pinned to 'html' for the preview itself, and the - // source tab needs the editor's own detection to pick a highlighter. - language: detectLanguage(document.filePath), - runtimeEnvironmentId: document.runtimeEnvironmentId, - ...(document.externalSshTargetId ? { externalSshTargetId: document.externalSshTargetId } : {}), - mode: 'edit' + openWorkspaceFile({ + kind: 'file', + intent: 'source', + file: { + filePath: document.filePath, + relativePath: document.relativePath, + worktreeId: document.worktreeId, + // Why not the preview tab's language: it is pinned to 'html' for the preview itself, and the + // source tab needs the editor's own detection to pick a highlighter. + language: detectLanguage(document.filePath), + runtimeEnvironmentId: document.runtimeEnvironmentId, + ...(document.externalSshTargetId + ? { externalSshTargetId: document.externalSshTargetId } + : {}), + mode: 'edit' + } }) } diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts index 6f04f5e23ab..306384392d2 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts @@ -60,7 +60,8 @@ describe('dropping a project file on the tab strip', () => { expect.objectContaining({ filePath: '/repo/docs-link/secret.md', relativePath: '/repo/docs-link/secret.md' - }) + }), + {} ) }) @@ -70,7 +71,8 @@ describe('dropping a project file on the tab strip', () => { await dropOnTabStrip('/repo/docs/plain.md') expect(mocks.openFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/repo/docs/plain.md', relativePath: 'docs/plain.md' }) + expect.objectContaining({ filePath: '/repo/docs/plain.md', relativePath: 'docs/plain.md' }), + {} ) }) }) diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.ts b/src/renderer/src/components/editor/editor-dropped-file-open.ts index ec5508f42d6..bc0f60951e7 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.ts @@ -13,8 +13,8 @@ import { translate } from '@/i18n/i18n' import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { statUserOpenedPath } from '@/lib/user-opened-local-path' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace' import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' +import { openWorkspaceFile } from '@/lib/workspace-file-open' export function shouldUploadRemoteEditorFileDrop( target: RuntimeClientTarget, @@ -101,6 +101,21 @@ export function editorGroupStillExists({ return groups.some((group) => group.id === groupId) } +function canOpenEditorFileDrop( + captured: EditorFileDropContext, + destination: EditorFileDropDestination +): boolean { + if (!editorGroupStillExists(destination)) { + return false + } + const current = captureEditorFileDropContext(destination.worktreeId) + if (!current || !sameEditorFileDropOwner(captured, current)) { + showOwnerChangedError() + return false + } + return true +} + /** * Captures the destination workspace's host at drop time and returns the open * step; a host change before preparation finishes refuses instead of re-routing. @@ -114,12 +129,7 @@ export function captureEditorFileDropOpen( return async () => undefined } return async (paths) => { - if (paths.length === 0 || !editorGroupStillExists(destination)) { - return - } - const current = captureEditorFileDropContext(destination.worktreeId) - if (!current || !sameEditorFileDropOwner(captured, current)) { - showOwnerChangedError() + if (paths.length === 0 || !canOpenEditorFileDrop(captured, destination)) { return } await openEditorFileDropPaths(captured, destination, paths) @@ -127,11 +137,11 @@ export function captureEditorFileDropOpen( } async function openEditorFileDropPaths( - { fileContext, worktreePath, connectionId }: EditorFileDropContext, + context: EditorFileDropContext, destination: EditorFileDropDestination, paths: readonly string[] ): Promise { - const store = useAppStore.getState() + const { fileContext, worktreePath, connectionId } = context const { worktreeId, groupId } = destination const groupOptions = groupId ? { targetGroupId: groupId } : null const runtimeEnvironmentId = runtimeTargetEnvironmentId(fileContext.target) @@ -155,20 +165,17 @@ async function openEditorFileDropPaths( destinationDir, { ensureDestinationDir: true, nameExtensionlessImages: true } ) - if (!editorGroupStillExists(destination)) { - return - } for (const result of results) { - if (!editorGroupStillExists(destination)) { + if (!canOpenEditorFileDrop(context, destination)) { return } if (result.status !== 'imported' || result.kind === 'directory') { continue } const maybeRelative = toWorktreeRelativePath(result.destPath, worktreePath) - store.setActiveTabType('editor', worktreeId) - store.openFile( - { + openWorkspaceFile({ + kind: 'file', + file: { filePath: result.destPath, relativePath: maybeRelative ?? result.destPath, worktreeId, @@ -176,8 +183,8 @@ async function openEditorFileDropPaths( language: detectLanguage(result.destPath), mode: 'edit' }, - { suppressActiveRuntimeFallback: runtimeEnvironmentId === null, ...groupOptions } - ) + options: { suppressActiveRuntimeFallback: runtimeEnvironmentId === null, ...groupOptions } + }) } if (results.some((result) => result.status !== 'imported')) { toast.error( @@ -199,7 +206,7 @@ async function openEditorFileDropPaths( for (const filePath of paths) { try { const stat = await statUserOpenedPath(fileContext, filePath) - if (!editorGroupStillExists(destination)) { + if (!canOpenEditorFileDrop(context, destination)) { return } if (stat.isDirectory) { @@ -214,27 +221,22 @@ async function openEditorFileDropPaths( relativePath = maybeRelative } } - store.setActiveTabType('editor', worktreeId) - if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - openDocumentInFloatingWorkspace( - store.openFile, - { filePath, relativePath }, - groupOptions ?? {} - ) - continue - } - const file = { - filePath, - relativePath, - worktreeId, - language: detectLanguage(filePath), - mode: 'edit' as const - } - if (groupOptions) { - store.openFile(file, groupOptions) - } else { - store.openFile(file) - } + const floating = worktreeId === FLOATING_TERMINAL_WORKTREE_ID + openWorkspaceFile({ + kind: 'file', + file: { + filePath, + relativePath, + worktreeId, + language: detectLanguage(filePath), + mode: 'edit', + ...(floating ? { runtimeEnvironmentId: null } : {}) + }, + options: { + ...groupOptions, + ...(floating ? { preview: false, suppressActiveRuntimeFallback: true } : {}) + } + }) } catch { // Ignore files that cannot be stat'd. } diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx index 1dfd6526b22..8276b546754 100644 --- a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx @@ -11,6 +11,7 @@ import { } from '@/lib/remote-workspace-drop-test-fixtures' import { joinPath } from '@/lib/path' import { useEditorGroupFileDropOwner } from './use-editor-group-file-drop-owner' +import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture' const mocks = vi.hoisted(() => ({ importPaths: vi.fn(), @@ -29,8 +30,8 @@ function EditorStrip({ worktreeId, groupId }: { worktreeId: string; groupId: str return
} -function dropFile(target: Element): void { - const transfer = { types: ['Files'], files: [new File(['x'], 'shot.png')], dropEffect: 'move' } +function dropFile(target: Element, name = 'shot.png'): void { + const transfer = { types: ['Files'], files: [new File(['x'], name)], dropEffect: 'move' } const event = new Event('drop', { bubbles: true, cancelable: true, composed: true }) Object.defineProperty(event, 'dataTransfer', { value: transfer }) Object.defineProperty(event, 'isTrusted', { value: true }) @@ -41,7 +42,20 @@ function dropFile(target: Element): void { beforeEach(() => { seedRemoteDropWorkspaces() - useAppStore.setState({ openFile: mocks.openFile, setActiveTabType: mocks.setActiveTabType }) + const response = createCompatibleRuntimeStatusResponse('owner-runtime') + if (!response.ok) { + throw new Error('Expected a compatible runtime fixture') + } + useAppStore.setState({ + openFile: mocks.openFile, + setActiveTabType: mocks.setActiveTabType, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + unifiedTabsByWorktree: {}, + runtimeStatusByEnvironmentId: new Map([ + ['owner-runtime', { status: response.result, checkedAt: Date.now() }] + ]) + }) mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] @@ -108,6 +122,79 @@ describe('editor group OS file drops on remote workspaces', () => { ) }) + it.each(['wt-ssh', 'wt-runtime'])( + 'previews uploaded HTML in its destination group in %s', + async (worktreeId) => { + const groupId = worktreeId === 'wt-ssh' ? 'group-ssh' : 'group-runtime' + const root = worktreeId === 'wt-ssh' ? SSH_PATH : RUNTIME_PATH + const view = render() + dropFile(view.getByTestId('strip'), 'report.html') + await waitFor(() => + expect(useAppStore.getState().browserTabsByWorktree[worktreeId]).toHaveLength(1) + ) + const state = useAppStore.getState() + const workspace = state.browserTabsByWorktree[worktreeId][0] + expect(state.browserPagesByWorkspace[workspace.id][0].docLocation).toEqual({ + kind: 'workspace-doc', + worktreeId, + filePath: joinPath(root, '.orca/drops/report.html') + }) + expect(state.unifiedTabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ contentType: 'browser', entityId: workspace.id, groupId }) + ]) + expect(mocks.openFile).not.toHaveBeenCalled() + } + ) + + it('opens uploaded HTML source when the runtime cannot serve scoped previews', async () => { + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + const view = render() + + dropFile(view.getByTestId('strip'), 'report.html') + + await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: joinPath(RUNTIME_PATH, '.orca/drops/report.html'), + worktreeId: 'wt-runtime', + runtimeEnvironmentId: 'owner-runtime' + }), + { suppressActiveRuntimeFallback: false, targetGroupId: 'group-runtime' } + ) + expect(useAppStore.getState().browserTabsByWorktree['wt-runtime'] ?? []).toHaveLength(0) + }) + + it('refuses an HTML preview when its workspace changes hosts during upload', async () => { + let finish: (result: { + results: { status: 'imported'; kind: 'file'; destPath: string }[] + }) => void = () => undefined + mocks.importPaths.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))) + const view = render() + dropFile(view.getByTestId('strip'), 'report.html') + await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => + repo.id === 'repo-rt' ? { ...repo, executionHostId: 'runtime:new-owner' as const } : repo + ) + })) + await act(async () => + finish({ + results: [ + { + status: 'imported', + kind: 'file', + destPath: joinPath(RUNTIME_PATH, '.orca/drops/report.html') + } + ] + }) + ) + expect(useAppStore.getState().browserTabsByWorktree['wt-runtime'] ?? []).toHaveLength(0) + expect(mocks.openFile).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith( + expect.stringMatching(/Couldn't verify which host owns/) + ) + }) + it.each(['wt-ssh', 'wt-runtime'])( 'opens nothing when the group closes during upload in %s and keeps preparation feedback', async (worktreeId) => { diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx index 55fa5d0b2d3..323a7478d55 100644 --- a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx @@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => { const groupsByWorktree: Record = {} return { openFile: vi.fn(), - setActiveTabType: vi.fn(), stat: vi.fn(), prepare: vi.fn(), toastError: vi.fn(), @@ -56,7 +55,6 @@ vi.mock('@/store', () => ({ activeWorktreeId: 'wt-active', groupsByWorktree: mocks.groupsByWorktree, getKnownWorktreeById: (id: string) => ({ id, path: `/repos/${id}` }), - setActiveTabType: mocks.setActiveTabType, openFile: mocks.openFile }) } @@ -149,7 +147,6 @@ describe('editor group OS file drops', () => { }), { targetGroupId: 'group-b' } ) - expect(mocks.setActiveTabType).toHaveBeenCalledWith('editor', 'wt-b') } ) @@ -262,7 +259,6 @@ describe('editor group OS file drops', () => { } await act(async () => finish({ isDirectory: false, escapesWorktree: false })) expect(mocks.openFile).not.toHaveBeenCalled() - expect(mocks.setActiveTabType).not.toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledTimes(1) } ) diff --git a/src/renderer/src/components/quick-open-file-navigation.test.ts b/src/renderer/src/components/quick-open-file-navigation.test.ts index c3ddc6f1aee..5586dde0b04 100644 --- a/src/renderer/src/components/quick-open-file-navigation.test.ts +++ b/src/renderer/src/components/quick-open-file-navigation.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => { authorize: vi.fn(), open: vi.fn(() => 'owner-file-id'), source: vi.fn(), + preview: vi.fn(), reveal: vi.fn(), assertCurrent: vi.fn(), rpc: vi.fn(), @@ -26,6 +27,10 @@ vi.mock('@/store', () => ({ }) } })) +vi.mock('@/lib/file-preview', () => ({ + openFileInBrowserTab: mocks.preview, + getWorkspaceFilePreviewPlan: () => ({ status: 'browser-tab' }) +})) vi.mock('@/runtime/runtime-file-metadata-client', () => ({ statRuntimePath: mocks.stat, isMissingRuntimePathError: (error: Error) => error.message.includes('ENOENT') @@ -52,9 +57,36 @@ beforeEach(() => { mocks.route.current = { target: { kind: 'local' }, connectionId: undefined } mocks.stat.mockResolvedValue({ isDirectory: false }) mocks.assertCurrent.mockReset() + mocks.preview.mockReturnValue({ status: 'unsupported' }) vi.stubGlobal('window', { api: { fs: { authorizeExternalPath: mocks.authorize } } }) }) +it('previews an HTML file selected without a line destination', async () => { + mocks.preview.mockReturnValue({ status: 'browser-tab' }) + await openQuickOpenFile('report.html', 'wt', '/repo', { pathQuery: 'report.html' }) + expect(mocks.preview).toHaveBeenCalledWith({ filePath: '/repo/report.html', worktreeId: 'wt' }) + expect(mocks.open).not.toHaveBeenCalled() + expect(mocks.reveal).not.toHaveBeenCalled() +}) + +it('opens HTML at the requested source line even when a preview is available', async () => { + mocks.preview.mockReturnValue({ status: 'browser-tab' }) + await openQuickOpenFile('report.html', 'wt', '/repo', { + pathQuery: 'report.html', + line: 12, + column: 3 + }) + expect(mocks.preview).not.toHaveBeenCalled() + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ language: 'html' }), undefined) + expect(mocks.reveal).toHaveBeenCalledWith( + expect.any(Function), + '/repo/report.html', + 12, + 3, + 'owner-file-id' + ) +}) + it('validates pasted local paths with user-named access and uses the owner-qualified reveal', async () => { await openQuickOpenFile('/external/guide.md', 'wt', '/repo', { pathQuery: '/external/guide.md', @@ -69,7 +101,8 @@ it('validates pasted local paths with user-named access and uses the owner-quali filePath: '/external/guide.md', relativePath: '/external/guide.md', runtimeEnvironmentId: null - }) + }), + undefined ) expect(mocks.source).toHaveBeenCalledWith('owner-file-id', 'source') expect(mocks.reveal).toHaveBeenCalledWith( @@ -89,7 +122,10 @@ it('keeps a literal colon filename when it exists instead of applying its appare { pathQuery: '/repo/report', line: 12 }, '/repo/report:12' ) - expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/repo/report:12' })) + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/report:12' }), + undefined + ) expect(mocks.reveal).not.toHaveBeenCalled() }) @@ -102,7 +138,10 @@ it('falls back from a missing literal colon filename to line navigation', async { pathQuery: '/repo/file.ts', line: 12 }, '/repo/file.ts:12' ) - expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/repo/file.ts' })) + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/file.ts' }), + undefined + ) expect(mocks.reveal).toHaveBeenCalledWith( expect.any(Function), '/repo/file.ts', @@ -121,7 +160,10 @@ it('probes SSH paths only on their owning host', async () => { '/external/file.ts', { kind: 'user-file' } ) - expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ externalSshTargetId: 'ssh-a' })) + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ externalSshTargetId: 'ssh-a' }), + undefined + ) }) it('resolves paired paths on the host and never falls back to client filesystem access', async () => { @@ -136,7 +178,8 @@ it('resolves paired paths on the host and never falls back to client filesystem expect(mocks.stat).not.toHaveBeenCalled() expect(mocks.authorize).not.toHaveBeenCalled() expect(mocks.open).toHaveBeenCalledWith( - expect.objectContaining({ runtimeEnvironmentId: 'env-a', relativePath: 'file.ts' }) + expect.objectContaining({ runtimeEnvironmentId: 'env-a', relativePath: 'file.ts' }), + undefined ) mocks.rpc.mockResolvedValue({ exists: true, @@ -200,7 +243,10 @@ it('resolves literal colon names on a paired host before treating the suffix as '/repo/report:12' ) expect(mocks.rpc).toHaveBeenCalledOnce() - expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/repo/report:12' })) + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/report:12' }), + undefined + ) expect(mocks.reveal).not.toHaveBeenCalled() expect(mocks.stat).not.toHaveBeenCalled() }) @@ -220,7 +266,10 @@ it('keeps a pasted POSIX file on the local workspace WSL distro before any files expect.anything(), '\\\\wsl.localhost\\Ubuntu\\home\\repo\\file.ts' ) - expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ relativePath: 'file.ts' })) + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ relativePath: 'file.ts' }), + undefined + ) mocks.route.current.connectionId = 'ssh-a' await openQuickOpenFile('/home/repo/file.ts', 'wt', '\\\\wsl.localhost\\Ubuntu\\home\\repo', { pathQuery: '/home/repo/file.ts' @@ -244,7 +293,8 @@ it.each(['literal:12', 'nested/literal:12', 'literal:12:3'])( query ) expect(mocks.open).toHaveBeenCalledWith( - expect.objectContaining({ filePath: `/repo/${selected}` }) + expect.objectContaining({ filePath: `/repo/${selected}` }), + undefined ) expect(mocks.reveal).not.toHaveBeenCalled() } diff --git a/src/renderer/src/components/quick-open-file-navigation.ts b/src/renderer/src/components/quick-open-file-navigation.ts index d0eb76c96b1..1d0bb2380bb 100644 --- a/src/renderer/src/components/quick-open-file-navigation.ts +++ b/src/renderer/src/components/quick-open-file-navigation.ts @@ -1,6 +1,7 @@ import { prepareQuickOpenFiles, rankQuickOpenFiles } from '../../../shared/quick-open-path-search' import { statUserOpenedPath } from '@/lib/user-opened-local-path' import { detectLanguage } from '@/lib/language-detect' +import { openWorkspaceFile } from '@/lib/workspace-file-open' import { joinPath, getRelativePathInsideRoot } from '@/lib/path' import { useAppStore } from '@/store' import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client' @@ -120,28 +121,27 @@ export async function openQuickOpenFile( } } assertCurrent() - const store = useAppStore.getState() - const fileId = store.openFile({ - filePath, - relativePath, - worktreeId, - runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null, - ...(route.connectionId && getRelativePathInsideRoot(filePath, root) === null - ? { externalSshTargetId: route.connectionId } - : {}), - language: detectLanguage(filePath), - mode: 'edit' + const revealLine = literalSelected ? undefined : navigation.line + const opened = openWorkspaceFile({ + kind: 'file', + intent: revealLine === undefined ? 'default' : 'source', + file: { + filePath, + relativePath, + worktreeId, + runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null, + ...(route.connectionId && getRelativePathInsideRoot(filePath, root) === null + ? { externalSshTargetId: route.connectionId } + : {}), + language: detectLanguage(filePath), + mode: 'edit' + } }) - if (!literalSelected && navigation.line !== undefined) { + if (revealLine !== undefined && opened.surface === 'editor') { + const fileId = opened.fileId if (detectLanguage(filePath) === 'markdown') { useAppStore.getState().setMarkdownViewMode(fileId, 'source') } - scheduleEditorLineReveal( - useAppStore.getState, - filePath, - navigation.line, - navigation.column, - fileId - ) + scheduleEditorLineReveal(useAppStore.getState, filePath, revealLine, navigation.column, fileId) } } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index aa28b0924a9..c724cc1643b 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -41,6 +41,7 @@ const mocks = vi.hoisted(() => { lastActivityAt: 0 } const calls = { + createBrowserTab: vi.fn(), openDiff: vi.fn(), openFile: vi.fn(), openConflictFile: vi.fn(), @@ -149,6 +150,10 @@ function resetState(overrides: Partial> = {}): void { activeWorktreeId: mocks.activeWorktree.id, activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' }, groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + createBrowserTab: mocks.calls.createBrowserTab, + getKnownWorktreeById: () => mocks.activeWorktree, repos: [mocks.activeRepo], worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, sshConnectionStates: new Map(), @@ -211,6 +216,7 @@ function resetState(overrides: Partial> = {}): void { clearDiffComments: noopAsync(true), clearDiffCommentsForFile: noopAsync(true), setScrollToDiffCommentId: vi.fn(), + closeAgentSendPopoverTargetMode: vi.fn(), setRightSidebarOpen: vi.fn(), setRightSidebarTab: vi.fn(), allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'), @@ -267,11 +273,10 @@ function doubleClickUncommitted(path: string): void { }) } -function clickBranchRow(init: MouseEventInit = {}): void { - const label = [...container.querySelectorAll('span')].find( - (candidate) => candidate.textContent === 'branch.ts' +function clickBranchRow(path = 'src/branch.ts', init: MouseEventInit = {}): void { + const row = container.querySelector( + `[data-source-control-area="branch"][data-source-control-path="${path}"]` ) - const row = label?.closest('div') expect(row).not.toBeNull() act(() => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init })) @@ -279,6 +284,260 @@ function clickBranchRow(init: MouseEventInit = {}): void { } describe('SourceControl preview row opens', () => { + it.each(['untracked', 'unstaged', 'staged'] as const)( + 'keeps the current-file indicator on an HTML browser preview in %s', + (area) => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ + path: 'report one.html', + area, + status: area === 'untracked' ? area : 'modified' + }) + ] + }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'browser' }, + activeBrowserTabIdByWorktree: { [mocks.activeWorktree.id]: 'browser-1' }, + browserTabsByWorktree: { + [mocks.activeWorktree.id]: [{ id: 'browser-1', activePageId: 'page-html' }] + }, + browserPagesByWorkspace: { + 'browser-1': [ + { id: 'page-web', url: 'https://example.com' }, + { id: 'page-html', url: 'file:///repo/wt/report%20one.html#section' } + ] + } + }) + renderSourceControl() + + expect( + container + .querySelector('[data-source-control-path="report one.html"]') + ?.getAttribute('data-current') + ).toBe('true') + } + ) + + it.each([mocks.activeWorktree.id, 'other-workspace'])( + 'matches a workspace document only to its owning workspace: %s', + (documentWorktreeId) => { + resetState({ + repos: [{ ...mocks.activeRepo, connectionId: 'ssh-1' }], + gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] }, + activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'browser' }, + activeBrowserTabIdByWorktree: { [mocks.activeWorktree.id]: 'browser-1' }, + browserTabsByWorktree: { + [mocks.activeWorktree.id]: [{ id: 'browser-1', activePageId: 'page-html' }] + }, + browserPagesByWorkspace: { + 'browser-1': [ + { + id: 'page-html', + url: 'data:text/html,', + docLocation: { + kind: 'workspace-doc', + worktreeId: documentWorktreeId, + filePath: '/repo/wt/test.html' + } + } + ] + } + }) + renderSourceControl() + + expect( + container + .querySelector('[data-source-control-path="test.html"]') + ?.getAttribute('data-current') + ).toBe(documentWorktreeId === mocks.activeWorktree.id ? 'true' : null) + } + ) + + it.each(['untracked', 'unstaged', 'staged'] as const)( + 'renders HTML when opening a row in %s', + (area) => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ path: 'test.html', area, status: area === 'untracked' ? area : 'modified' }) + ] + } + }) + renderSourceControl() + + clickUncommitted('test.html') + + expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith( + mocks.activeWorktree.id, + 'file:///repo/wt/test.html', + { title: 'test.html', targetGroupId: undefined, activate: true } + ) + expect(mocks.calls.openDiff).not.toHaveBeenCalled() + expect(mocks.calls.openFile).not.toHaveBeenCalled() + } + ) + + it('targets the requested split when previewing HTML', () => { + resetState({ + gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] } + }) + renderSourceControl() + + clickUncommitted('test.html', { ctrlKey: true }) + + expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith( + mocks.activeWorktree.id, + 'file:///repo/wt/test.html', + { title: 'test.html', targetGroupId: 'group-2', activate: true } + ) + expect(mocks.calls.openDiff).not.toHaveBeenCalled() + }) + + it('previews HTML through its SSH owner', () => { + resetState({ + repos: [{ ...mocks.activeRepo, connectionId: 'ssh-1' }], + gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] } + }) + renderSourceControl() + + clickUncommitted('test.html') + + expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith( + mocks.activeWorktree.id, + 'data:text/html,', + expect.objectContaining({ + docLocation: { + kind: 'workspace-doc', + worktreeId: mocks.activeWorktree.id, + filePath: '/repo/wt/test.html' + }, + browserRuntimeEnvironmentId: null, + activate: true + }) + ) + expect(mocks.calls.openDiff).not.toHaveBeenCalled() + }) + + it('keeps deleted HTML and HTML conflicts in their diff surfaces', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ path: 'deleted.html', status: 'deleted' }), + gitEntry({ + path: 'conflict.html', + conflictKind: 'both_modified', + conflictStatus: 'unresolved' + }) + ] + } + }) + renderSourceControl() + + clickUncommitted('deleted.html') + clickUncommitted('conflict.html') + + expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.calls.openDiff).toHaveBeenCalledWith( + mocks.activeWorktree.id, + '/repo/wt/deleted.html', + 'deleted.html', + 'html', + false, + { targetGroupId: undefined, preview: true } + ) + expect(mocks.calls.openConflictFile).toHaveBeenCalledWith( + mocks.activeWorktree.id, + '/repo/wt', + expect.objectContaining({ path: 'conflict.html' }), + 'html', + { targetGroupId: undefined, preview: true } + ) + }) + + it('falls back to the diff when this client cannot preview HTML', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + onTestFinished(() => { + vi.unstubAllGlobals() + }) + resetState({ + gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] } + }) + renderSourceControl() + + clickUncommitted('test.html') + + expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.calls.openDiff).toHaveBeenCalledWith( + mocks.activeWorktree.id, + '/repo/wt/test.html', + 'test.html', + 'html', + false, + { targetGroupId: undefined, preview: true } + ) + }) + + it.each(['uncommitted', 'branch'] as const)('opens a %s HTML review note in the diff', (area) => { + resetState({ + worktreesByRepo: { + [mocks.activeRepo.id]: [ + { + ...mocks.activeWorktree, + diffComments: [ + { + id: 'html-note', + worktreeId: mocks.activeWorktree.id, + filePath: 'test.html', + lineNumber: 1, + side: 'modified', + body: 'Review this heading', + createdAt: 1 + } + ] + } + ] + }, + gitStatusByWorktree: { + [mocks.activeWorktree.id]: area === 'uncommitted' ? [gitEntry({ path: 'test.html' })] : [] + }, + gitBranchChangesByWorktree: { + [mocks.activeWorktree.id]: area === 'branch' ? [branchEntry({ path: 'test.html' })] : [] + }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: branchSummary() } + }) + renderSourceControl() + + const expandNotes = container.querySelector('button[title="Expand notes"]') + expect(expandNotes).not.toBeNull() + act(() => expandNotes?.click()) + const note = container.querySelector('button[aria-label^="Open note on"]') + expect(note?.textContent).toContain('Review this heading') + act(() => note?.click()) + + expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled() + if (area === 'uncommitted') { + expect(mocks.calls.openDiff).toHaveBeenCalledWith( + mocks.activeWorktree.id, + '/repo/wt/test.html', + 'test.html', + 'html', + false, + { preview: true } + ) + } else { + expect(mocks.calls.openBranchDiff).toHaveBeenCalledWith( + mocks.activeWorktree.id, + '/repo/wt', + expect.objectContaining({ path: 'test.html' }), + branchSummary(), + 'html', + { preview: true } + ) + } + expect(mocks.state.setScrollToDiffCommentId).toHaveBeenLastCalledWith('html-note') + }) + it('passes preview=true when plain uncommitted row clicks open diff tabs', () => { resetState({ gitStatusByWorktree: { @@ -531,6 +790,68 @@ describe('SourceControl preview row opens', () => { ) }) + it.each([false, true])('previews committed HTML from a branch row (split: %s)', (split) => { + resetState({ + gitBranchChangesByWorktree: { + [mocks.activeWorktree.id]: [branchEntry({ path: 'test.html' })] + }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: branchSummary() } + }) + renderSourceControl() + + clickBranchRow('test.html', { ctrlKey: split }) + + expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith( + mocks.activeWorktree.id, + 'file:///repo/wt/test.html', + { title: 'test.html', targetGroupId: split ? 'group-2' : undefined, activate: true } + ) + expect(mocks.calls.openBranchDiff).not.toHaveBeenCalled() + }) + + it.each(['branch-deleted', 'worktree-deleted', 'worktree-conflict'] as const)( + 'keeps a %s HTML branch row in the diff', + (kind) => { + resetState({ + gitBranchChangesByWorktree: { + [mocks.activeWorktree.id]: [ + branchEntry({ + path: 'test.html', + status: kind === 'branch-deleted' ? 'deleted' : 'added' + }) + ] + }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: branchSummary() }, + gitStatusByWorktree: { + [mocks.activeWorktree.id]: + kind === 'branch-deleted' + ? [] + : [ + gitEntry({ + path: 'test.html', + ...(kind === 'worktree-deleted' + ? { status: 'deleted' } + : { conflictKind: 'both_modified', conflictStatus: 'unresolved' }) + }) + ] + } + }) + renderSourceControl() + + clickBranchRow('test.html') + + expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.calls.openBranchDiff).toHaveBeenCalledWith( + mocks.activeWorktree.id, + '/repo/wt', + expect.objectContaining({ path: 'test.html' }), + branchSummary(), + 'html', + { targetGroupId: undefined, preview: true } + ) + } + ) + it('passes preview=true when a plain branch row click opens a branch diff tab', () => { resetState({ gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [branchEntry()] }, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx index 6930a374a97..9f4b2856e18 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx @@ -280,7 +280,7 @@ function HandlersProbe({ scrollRef }: { scrollRef: React.RefObject void) { return { activeWorktreeId: 'wt-1', - openFile: vi.fn(), + openWorkspaceFile: vi.fn(), makePreviewFilePermanent: vi.fn(), toggleDir, loadDir: vi.fn().mockResolvedValue(true), @@ -62,7 +62,11 @@ function createHandlerParams(toggleDir: (worktreeId: string, dirPath: string) => function renderHandlers(toggleDir: (worktreeId: string, dirPath: string) => void) { const params = createHandlerParams(toggleDir) const hook = renderHook(() => useFileExplorerHandlers(params)) - return { ...hook, openFile: params.openFile, setSelectedPath: params.setSelectedPath } + return { + ...hook, + openWorkspaceFile: params.openWorkspaceFile, + setSelectedPath: params.setSelectedPath + } } // Wires a row like the explorer does so clicks resolve timing in handleRowClick. @@ -183,7 +187,7 @@ describe('instant directory toggle', () => { it('keeps opening files on the second click, which is not a directory toggle', async () => { const toggleDir = vi.fn() - const { result, openFile } = renderHandlers(toggleDir) + const { result, openWorkspaceFile } = renderHandlers(toggleDir) await act(async () => { result.current.handleClick(fileNode, 'skip') @@ -191,6 +195,6 @@ describe('instant directory toggle', () => { }) expect(toggleDir).not.toHaveBeenCalled() - expect(openFile).toHaveBeenCalledTimes(1) + expect(openWorkspaceFile).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx index d4136c63271..c669eedd623 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx @@ -41,7 +41,7 @@ function render( worktreePath={join(tmpdir(), 'wt-1')} revealInExplorer={vi.fn()} activeConnectionId={null} - openCommittedDiff={vi.fn()} + openBranchFile={vi.fn()} openBranchAllDiffs={vi.fn()} diffCommentCountByPath={new Map()} /> diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/active-open-file-keys.ts b/src/renderer/src/components/right-sidebar/source-control/listing/active-open-file-keys.ts index 42f14951f44..d27f42d73d5 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/active-open-file-keys.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/active-open-file-keys.ts @@ -1,9 +1,56 @@ import type { DiffSource } from '@/store/slices/editor' +import type { AppState } from '@/store/types' +import { getRelativePathInsideRoot } from '@/lib/path' +import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner' +import { getBrowserPageRuntimeEnvironmentId } from '@/components/browser-pane/describe-page/browser-page-url-display' +import { browserFileUrlToAbsolutePath } from '@/components/browser-pane/describe-page/browser-artifact-upload' +import { canOpenBrowserFileInWorkspaceEditor } from '@/components/browser-pane/navigate/browser-file-editor-open' const EMPTY_OPEN_ROW_KEYS: ReadonlySet = new Set() const SIGNATURE_SEPARATOR = '::' +export function getActiveOpenFileSignature( + state: AppState, + worktreeId: string | null, + worktreePath: string | null +): string | null { + if (!worktreeId) { + return null + } + if (state.activeTabTypeByWorktree?.[worktreeId] === 'editor') { + const activeFileId = state.activeFileIdByWorktree?.[worktreeId] + const activeFile = state.openFiles?.find( + (file) => file.id === activeFileId && file.worktreeId === worktreeId + ) + return activeFile + ? buildActiveOpenFileSignature(activeFile.diffSource, activeFile.relativePath) + : null + } + if (state.activeTabTypeByWorktree?.[worktreeId] !== 'browser') { + return null + } + const workspaceId = state.activeBrowserTabIdByWorktree?.[worktreeId] + const workspace = state.browserTabsByWorktree?.[worktreeId]?.find((tab) => tab.id === workspaceId) + const pages = workspaceId ? state.browserPagesByWorkspace?.[workspaceId] : undefined + const page = pages?.find((candidate) => candidate.id === workspace?.activePageId) ?? pages?.[0] + if (!page) { + return null + } + let filePath: string | null = null + if (page.docLocation?.worktreeId === worktreeId) { + filePath = page.docLocation.filePath + } else if ( + !page.docLocation && + !getBrowserPageRuntimeEnvironmentId(page, runtimeEnvironmentIdUnlessTwin(state, worktreeId)) && + canOpenBrowserFileInWorkspaceEditor(state, worktreeId) + ) { + filePath = browserFileUrlToAbsolutePath(page.url) + } + const relativePath = filePath ? getRelativePathInsideRoot(filePath, worktreePath) : null + return relativePath ? buildActiveOpenFileSignature(undefined, relativePath) : null +} + /** * Build the stable string that identifies the active editor file for highlight * matching: `${diffSource}::${relativePath}`. Kept as a primitive so the zustand diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx index de5a4148369..13367929e53 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx @@ -48,6 +48,8 @@ export function BranchEntryRow({ onRevealInExplorer={onRevealInExplorer} >
void activeConnectionId: string | null - openCommittedDiff: (entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => void + openBranchFile: (entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => void openBranchAllDiffs: ( worktreeId: string, worktreePath: string, @@ -130,7 +130,7 @@ export function SourceControlBranchSection({ depth={node.depth} onRevealInExplorer={revealInExplorer} connectionId={activeConnectionId} - onOpen={(event) => openCommittedDiff(node.entry, event)} + onOpen={(event) => openBranchFile(node.entry, event)} commentCount={diffCommentCountByPath.get(node.entry.path) ?? 0} showPathHint={false} /> @@ -150,7 +150,7 @@ export function SourceControlBranchSection({ worktreePath={worktreePath} onRevealInExplorer={revealInExplorer} connectionId={activeConnectionId} - onOpen={(event) => openCommittedDiff(entry, event)} + onOpen={(event) => openBranchFile(entry, event)} commentCount={diffCommentCountByPath.get(entry.path) ?? 0} /> )} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.test.ts b/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.test.ts index ea92fc97491..6d564219c1a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { getEditorPanelRenderModel } from '@/components/editor/editor-panel-render-model' import { createEditorStore } from '@/store/slices/editor-slice-test-harness' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' -import { openUncommittedEntry } from './open-uncommitted-entry' +import { openUncommittedEntry } from '@/lib/workspace-file-changes-open' vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/close-mirrored-editor-tab', () => ({ diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx index 3a9ed14e9c2..d87e34a6d2b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-action-buttons.test.tsx @@ -65,7 +65,7 @@ function renderUncommittedSections({ handleContextMenu={vi.fn()} revealInExplorer={vi.fn()} activeConnectionId={null} - handleOpenDiff={vi.fn()} + openUncommittedFile={vi.fn()} handleStage={vi.fn()} handleUnstage={vi.fn()} requestDiscardEntry={vi.fn()} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx index 2d71a640259..77ccd9a2588 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx @@ -37,7 +37,7 @@ export function SourceControlSectionFileList({ handleContextMenu, revealInExplorer, activeConnectionId, - handleOpenDiff, + openUncommittedFile, handleStage, handleUnstage, requestDiscardEntry, @@ -64,7 +64,7 @@ export function SourceControlSectionFileList({ handleContextMenu: (key: string) => void revealInExplorer: (worktreeId: string, absolutePath: string) => void activeConnectionId: string | null - handleOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void + openUncommittedFile: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void handleStage: (path: string) => Promise handleUnstage: (path: string, oldPath?: string) => Promise requestDiscardEntry: (entry: GitStatusEntry) => void @@ -122,7 +122,7 @@ export function SourceControlSectionFileList({ onContextMenu={handleContextMenu} onRevealInExplorer={revealInExplorer} connectionId={activeConnectionId} - onOpen={handleOpenDiff} + onOpen={openUncommittedFile} onStage={handleStage} onUnstage={handleUnstage} onDiscard={requestDiscardEntry} @@ -173,7 +173,7 @@ export function SourceControlSectionFileList({ onContextMenu={handleContextMenu} onRevealInExplorer={revealInExplorer} connectionId={activeConnectionId} - onOpen={handleOpenDiff} + onOpen={openUncommittedFile} onStage={handleStage} onUnstage={handleUnstage} onDiscard={requestDiscardEntry} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx index 996340c9f22..70640733aa0 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx @@ -86,7 +86,7 @@ export function SourceControlUncommittedSections(props: { handleContextMenu: (key: string) => void revealInExplorer: (worktreeId: string, absolutePath: string) => void activeConnectionId: string | null - handleOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void + openUncommittedFile: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void handleStage: (path: string) => Promise handleUnstage: (path: string, oldPath?: string) => Promise requestDiscardEntry: (entry: GitStatusEntry) => void @@ -215,7 +215,7 @@ export function SourceControlUncommittedSections(props: { handleContextMenu={props.handleContextMenu} revealInExplorer={props.revealInExplorer} activeConnectionId={props.activeConnectionId} - handleOpenDiff={props.handleOpenDiff} + openUncommittedFile={props.openUncommittedFile} handleStage={props.handleStage} handleUnstage={props.handleUnstage} requestDiscardEntry={props.requestDiscardEntry} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts index 8f65b796e3a..653d0f4fe7b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-file-listing.ts @@ -115,7 +115,7 @@ export function useSourceControlFileListing({ }) // Why: modifier-click keeps the current pane intact by opening the file in a fresh split to the right. - const { resolveSplitTargetGroupId, activeOpenRowKeys, handleOpenDiff, openCommittedDiff } = + const { resolveSplitTargetGroupId, activeOpenRowKeys, openUncommittedFile, openBranchFile } = useSourceControlRowOpening({ isMac, activeWorktreeId, @@ -131,7 +131,7 @@ export function useSourceControlFileListing({ const { selectedKeys, handleSelect, handleContextMenu, clearSelection } = useSourceControlSelection({ flatEntries: visibleSelectionEntries, - onOpenDiff: handleOpenDiff, + onOpenFile: openUncommittedFile, shouldOpenAsSplit, containerRef: sourceControlRef }) @@ -212,7 +212,7 @@ export function useSourceControlFileListing({ handleBulkStage, handleBulkUnstage, handleContextMenu, - handleOpenDiff, + openUncommittedFile, handleSelect, handleStageAllPaths, handleStageAllPrimary, @@ -222,7 +222,7 @@ export function useSourceControlFileListing({ isExecutingBulk, isGitHistoryVisible, normalizedFilter, - openCommittedDiff, + openBranchFile, pushRecovery, refreshGitHistory, refreshGitHistoryRef, diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts index 0c305e4b665..eb6d424896c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-row-opening.ts @@ -1,13 +1,12 @@ import { useCallback, useMemo } from 'react' -import { detectLanguage } from '@/lib/language-detect' -import { openUncommittedEntry } from './open-uncommitted-entry' +import { openWorkspaceFile } from '@/lib/workspace-file-open' import { useAppStore } from '@/store' import type { GitBranchChangeEntry, GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' -import { buildActiveOpenFileSignature, buildActiveOpenRowKeys } from './active-open-file-keys' +import { getActiveOpenFileSignature, buildActiveOpenRowKeys } from './active-open-file-keys' import type { FlatEntry } from './use-selection' import { isSourceControlSplitOpenModifier, @@ -31,11 +30,6 @@ export function useSourceControlRowOpening({ const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) const createEmptySplitGroup = useAppStore((s) => s.createEmptySplitGroup) - const trackConflictPath = useAppStore((s) => s.trackConflictPath) - const openConflictFile = useAppStore((s) => s.openConflictFile) - const openDiff = useAppStore((s) => s.openDiff) - const openFile = useAppStore((s) => s.openFile) - const openBranchDiff = useAppStore((s) => s.openBranchDiff) // Why: modifier-click keeps the current pane intact by opening the file in a fresh split to the right. const resolveSplitTargetGroupId = useCallback( @@ -53,22 +47,9 @@ export function useSourceControlRowOpening({ [activeGroupIdByWorktree, activeWorktreeId, createEmptySplitGroup, groupsByWorktree, isMac] ) - // Why: a stable string signature keeps this selector referentially stable so the panel re-renders only when the active editor file changes; null when the tab isn't an editor. - const activeOpenFileSignature = useAppStore((s) => { - if (!activeWorktreeId || s.activeTabTypeByWorktree?.[activeWorktreeId] !== 'editor') { - return null - } - const activeFileId = s.activeFileIdByWorktree?.[activeWorktreeId] - if (!activeFileId) { - return null - } - const activeFile = s.openFiles?.find( - (file) => file.id === activeFileId && file.worktreeId === activeWorktreeId - ) - return activeFile - ? buildActiveOpenFileSignature(activeFile.diffSource, activeFile.relativePath) - : null - }) + const activeOpenFileSignature = useAppStore((s) => + getActiveOpenFileSignature(s, activeWorktreeId, worktreePath) + ) const activeOpenAvailableRowKeys = useMemo(() => { const keys = new Set() for (const entry of visibleSelectionEntries) { @@ -81,33 +62,27 @@ export function useSourceControlRowOpening({ [activeOpenAvailableRowKeys, activeOpenFileSignature] ) - const handleOpenDiff = useCallback( + const openUncommittedFile = useCallback( (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => { if (!activeWorktreeId || !worktreePath) { return } const targetGroupId = resolveSplitTargetGroupId(event) - const openAsPreview = shouldOpenSourceControlRowAsPreview(event, targetGroupId) - openUncommittedEntry( - { trackConflictPath, openConflictFile, openFile, openDiff }, - activeWorktreeId, + openWorkspaceFile({ + kind: 'uncommitted', + worktreeId: activeWorktreeId, worktreePath, entry, - { targetGroupId, preview: openAsPreview } - ) + options: { + targetGroupId, + preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) + } + }) }, - [ - activeWorktreeId, - worktreePath, - resolveSplitTargetGroupId, - trackConflictPath, - openConflictFile, - openDiff, - openFile - ] + [activeWorktreeId, worktreePath, resolveSplitTargetGroupId] ) - const openCommittedDiff = useCallback( + const openBranchFile = useCallback( (entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => { if ( !activeWorktreeId || @@ -118,17 +93,20 @@ export function useSourceControlRowOpening({ return } const targetGroupId = resolveSplitTargetGroupId(event) - openBranchDiff( - activeWorktreeId, + openWorkspaceFile({ + kind: 'branch', + worktreeId: activeWorktreeId, worktreePath, entry, - branchSummary, - detectLanguage(entry.path), - { targetGroupId, preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) } - ) + compare: branchSummary, + options: { + targetGroupId, + preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) + } + }) }, - [activeWorktreeId, branchSummary, openBranchDiff, resolveSplitTargetGroupId, worktreePath] + [activeWorktreeId, branchSummary, resolveSplitTargetGroupId, worktreePath] ) - return { resolveSplitTargetGroupId, activeOpenRowKeys, handleOpenDiff, openCommittedDiff } + return { resolveSplitTargetGroupId, activeOpenRowKeys, openUncommittedFile, openBranchFile } } diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts index 3c493a1b5e4..4d202288d00 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts @@ -62,12 +62,12 @@ export function getSelectionRangeKeys( export function useSourceControlSelection({ flatEntries, - onOpenDiff, + onOpenFile, shouldOpenAsSplit, containerRef }: { flatEntries: FlatEntry[] - onOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void + onOpenFile: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void shouldOpenAsSplit?: (event: SourceControlRowOpenEvent) => boolean containerRef: RefObject }) { @@ -76,7 +76,7 @@ export function useSourceControlSelection({ const flatEntriesRef = useRef(flatEntries) const anchorKeyRef = useRef(anchorKey) const selectedKeysRef = useRef(selectedKeys) - const onOpenDiffRef = useRef(onOpenDiff) + const onOpenFileRef = useRef(onOpenFile) const shouldOpenAsSplitRef = useRef(shouldOpenAsSplit) useEffect(() => { @@ -92,8 +92,8 @@ export function useSourceControlSelection({ }, [selectedKeys]) useEffect(() => { - onOpenDiffRef.current = onOpenDiff - }, [onOpenDiff]) + onOpenFileRef.current = onOpenFile + }, [onOpenFile]) useEffect(() => { shouldOpenAsSplitRef.current = shouldOpenAsSplit @@ -118,7 +118,7 @@ export function useSourceControlSelection({ if (shouldOpenAsSplitRef.current?.(e)) { setSelectedKeys((prev) => (prev.size > 0 ? new Set() : prev)) setAnchorKey(null) - onOpenDiffRef.current(entry, e) + onOpenFileRef.current(entry, e) return } @@ -138,7 +138,7 @@ export function useSourceControlSelection({ // stale invisible anchor. setSelectedKeys(new Set()) setAnchorKey(key) - onOpenDiffRef.current(entry) + onOpenFileRef.current(entry) } else if (isCmdOrCtrl) { if (selectedKeysRef.current.has(key)) { setSelectedKeys((prev) => { @@ -163,7 +163,7 @@ export function useSourceControlSelection({ return prev }) setAnchorKey(key) - onOpenDiffRef.current(entry) + onOpenFileRef.current(entry) } }, []) diff --git a/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts b/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts index ef616d4b7a7..396ef70ae5d 100644 --- a/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts +++ b/src/renderer/src/components/right-sidebar/source-control/notes/use-note-opening.ts @@ -9,6 +9,7 @@ import type { } from '../../../../../../shared/git-diff-compare-types' import type { DiffComment } from '../../../../../../shared/diff-comment-types' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import { openWorkspaceFile } from '@/lib/workspace-file-open' import { cancelSourceControlEditorRevealFrames, requestSourceControlEditorRevealFrame @@ -25,8 +26,6 @@ export function useSourceControlNoteOpening({ entries, branchEntries, branchSummary, - handleOpenDiff, - openCommittedDiff, sourceControlRef }: { activeWorktreeId: string | null @@ -34,8 +33,6 @@ export function useSourceControlNoteOpening({ entries: GitStatusEntry[] branchEntries: GitBranchChangeEntry[] branchSummary: GitBranchCompareSummary | null - handleOpenDiff: (entry: GitStatusEntry) => void - openCommittedDiff: (entry: GitBranchChangeEntry) => void sourceControlRef: RefObject }): SourceControlNoteOpening { const openFile = useAppStore((s) => s.openFile) @@ -98,7 +95,14 @@ export function useSourceControlNoteOpening({ matches.find((e) => e.area === 'untracked') ?? matches[0] if (uncommitted) { - handleOpenDiff(uncommitted) + openWorkspaceFile({ + kind: 'uncommitted', + intent: 'diff', + worktreeId: activeWorktreeId, + worktreePath, + entry: uncommitted, + options: { preview: true } + }) if (commentId) { setScrollToDiffCommentId(commentId) } @@ -106,7 +110,15 @@ export function useSourceControlNoteOpening({ } const branchEntry = branchEntries.find((e) => e.path === filePath) if (branchEntry && branchSummary?.status === 'ready') { - openCommittedDiff(branchEntry) + openWorkspaceFile({ + kind: 'branch', + intent: 'diff', + worktreeId: activeWorktreeId, + worktreePath, + entry: branchEntry, + compare: branchSummary, + options: { preview: true } + }) if (commentId) { setScrollToDiffCommentId(commentId) } @@ -132,8 +144,6 @@ export function useSourceControlNoteOpening({ branchEntries, branchSummary, entries, - handleOpenDiff, - openCommittedDiff, openFile, setEditorViewMode, setScrollToDiffCommentId, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx index 1e752f00137..81bad8e2fad 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx @@ -32,7 +32,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { handleAbortOperationForConflict, handleCommitAction, handleContextMenu, - handleOpenDiff, + openUncommittedFile, handleResolveConflictsWithAI, handleSelect, handleStage, @@ -50,7 +50,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { openAllDiffs, openBranchAllDiffs, openCommitFile, - openCommittedDiff, + openBranchFile, openConflictReview, openHistoryCommitDiff, refreshActiveGitStatus, @@ -170,7 +170,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { handleContextMenu={handleContextMenu} revealInExplorer={revealInExplorer} activeConnectionId={activeConnectionId} - handleOpenDiff={handleOpenDiff} + openUncommittedFile={openUncommittedFile} handleStage={handleStage} handleUnstage={handleUnstage} requestDiscardEntry={requestDiscardEntry} @@ -207,7 +207,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { worktreePath={worktreePath} revealInExplorer={revealInExplorer} activeConnectionId={activeConnectionId} - openCommittedDiff={openCommittedDiff} + openBranchFile={openBranchFile} openBranchAllDiffs={openBranchAllDiffs} diffCommentCountByPath={diffCommentCountByPath} /> diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts index 24980954fed..ec59f52c9a2 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/use-panel-model.ts @@ -40,7 +40,6 @@ export function useSourceControlPanelModel() { entries, fetchUpstreamStatus, grouped, - handleOpenDiff, handleStageAllPrimary, hostedReview, hostedReviewCreateCopy, @@ -59,7 +58,6 @@ export function useSourceControlPanelModel() { isHostedReviewStateLoading, isRemoteOperationActive, openModal, - openCommittedDiff, refreshActiveGitStatusAfterMutation, remoteStatus, remoteStatusForActions, @@ -155,8 +153,6 @@ export function useSourceControlPanelModel() { entries, branchEntries, branchSummary, - handleOpenDiff, - openCommittedDiff, sourceControlRef }) const entryMutations = useSourceControlEntryMutations({ diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts index 680721a78c4..6767c75d53e 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner' import { useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' +import { openWorkspaceFile } from '@/lib/workspace-file-open' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import type { OpenFile } from '@/store/slices/editor' import type { Repo } from '../../../../shared/repo-types' @@ -132,7 +133,6 @@ export function useFileExplorerTreePaneState({ runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(s, activeWorktreeId) ) const toggleDir = useAppStore((s) => s.toggleDir) - const openFile = useAppStore((s) => s.openFile) const makePreviewFilePermanent = useAppStore((s) => s.makePreviewFilePermanent) const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) const closeFile = useAppStore((s) => s.closeFile) @@ -251,7 +251,7 @@ export function useFileExplorerTreePaneState({ const handlers = useFileExplorerHandlers({ activeWorktreeId, runtimeEnvironmentId: ownerRuntimeEnvironmentId, - openFile, + openWorkspaceFile, makePreviewFilePermanent, toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir, loadDir, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts index efe5f476744..aa43a7c6684 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts @@ -35,7 +35,7 @@ describe('activateFileExplorerNode', () => { await activateFileExplorerNode({ node: directoryNode, activeWorktreeId: 'wt-1', - openFile: vi.fn(), + openWorkspaceFile: vi.fn(), toggleDir, canToggleDirectories: false, loadDir: vi.fn(), @@ -52,12 +52,12 @@ describe('activateFileExplorerNode', () => { const loadDir = vi.fn().mockResolvedValue(true) const markPathAsDirectory = vi.fn() const toggleDir = vi.fn() - const openFile = vi.fn() + const openWorkspaceFile = vi.fn() await activateFileExplorerNode({ node: symlinkNode, activeWorktreeId: 'wt-1', - openFile, + openWorkspaceFile, toggleDir, loadDir, statPath: vi.fn().mockResolvedValue({ isDirectory: true }), @@ -72,17 +72,17 @@ describe('activateFileExplorerNode', () => { }) expect(markPathAsDirectory).toHaveBeenCalledWith('/repo/linked-docs') expect(toggleDir).toHaveBeenCalledWith('wt-1', '/repo/linked-docs') - expect(openFile).not.toHaveBeenCalled() + expect(openWorkspaceFile).not.toHaveBeenCalled() }) it('does not follow a folder link that leads out of the project', async () => { const loadDir = vi.fn() - const openFile = vi.fn() + const openWorkspaceFile = vi.fn() await activateFileExplorerNode({ node: { ...symlinkNode, operationOwner: { kind: 'local' } }, activeWorktreeId: 'wt-1', - openFile, + openWorkspaceFile, toggleDir: vi.fn(), loadDir, statPath: vi.fn().mockResolvedValue({ isDirectory: true, escapesWorktree: true }), @@ -91,14 +91,14 @@ describe('activateFileExplorerNode', () => { }) expect(loadDir).not.toHaveBeenCalled() - expect(openFile).not.toHaveBeenCalled() + expect(openWorkspaceFile).not.toHaveBeenCalled() expect(toastError).toHaveBeenCalledWith( "This folder links outside the project, so it can't be opened here." ) }) it('opens a file link that leads out of the project by its absolute path', async () => { - const openFile = vi.fn() + const openWorkspaceFile = vi.fn() useAppStore.setState({ worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never] @@ -109,7 +109,7 @@ describe('activateFileExplorerNode', () => { node: { ...symlinkNode, operationOwner: { kind: 'local' } }, activeWorktreeId: 'wt-1', runtimeEnvironmentId: null, - openFile, + openWorkspaceFile, toggleDir: vi.fn(), loadDir: vi.fn(), statPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: true }), @@ -118,18 +118,21 @@ describe('activateFileExplorerNode', () => { }) // The absolute relativePath marks the tab as user-named, which survives a restart. - expect(openFile).toHaveBeenCalledWith( + expect(openWorkspaceFile).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '/repo/linked-docs', - relativePath: '/repo/linked-docs', - worktreeId: 'wt-1' - }), - expect.anything() + kind: 'file', + file: expect.objectContaining({ + filePath: '/repo/linked-docs', + relativePath: '/repo/linked-docs', + worktreeId: 'wt-1' + }), + options: expect.anything() + }) ) }) it('opens a symlink as a file when target stat fails', async () => { - const openFile = vi.fn() + const openWorkspaceFile = vi.fn() useAppStore.setState({ worktreesByRepo: { 'repo-1': [ @@ -147,7 +150,7 @@ describe('activateFileExplorerNode', () => { node: symlinkNode, activeWorktreeId: 'wt-1', runtimeEnvironmentId: 'runtime-env-1', - openFile, + openWorkspaceFile, toggleDir: vi.fn(), loadDir: vi.fn(), statPath: vi.fn().mockRejectedValue(new Error('stat failed')), @@ -155,16 +158,19 @@ describe('activateFileExplorerNode', () => { setSelectedPath: vi.fn() }) - expect(openFile).toHaveBeenCalledWith( - { - filePath: '/repo/linked-docs', - relativePath: 'linked-docs', - worktreeId: 'wt-1', - runtimeEnvironmentId: 'runtime-env-1', - language: expect.any(String), - mode: 'edit' - }, - { preview: true, focusEditor: true, suppressActiveRuntimeFallback: false } + expect(openWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'file', + file: { + filePath: '/repo/linked-docs', + relativePath: 'linked-docs', + worktreeId: 'wt-1', + runtimeEnvironmentId: 'runtime-env-1', + language: expect.any(String), + mode: 'edit' + }, + options: { preview: true, focusEditor: true, suppressActiveRuntimeFallback: false } + }) ) }) @@ -177,7 +183,7 @@ describe('activateFileExplorerNode', () => { depth: 0, operationOwner: { kind: 'local' } } - const openFile = vi.fn() + const openWorkspaceFile = vi.fn() useAppStore.setState({ worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never] @@ -188,7 +194,7 @@ describe('activateFileExplorerNode', () => { node: fileNode, activeWorktreeId: 'wt-1', runtimeEnvironmentId: null, - openFile, + openWorkspaceFile, toggleDir: vi.fn(), loadDir: vi.fn(), statPath: vi.fn(), @@ -196,12 +202,15 @@ describe('activateFileExplorerNode', () => { setSelectedPath: vi.fn() }) - expect(openFile).toHaveBeenCalledWith( + expect(openWorkspaceFile).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '/repo/README.md', - runtimeEnvironmentId: undefined - }), - { preview: true, focusEditor: true, suppressActiveRuntimeFallback: true } + kind: 'file', + file: expect.objectContaining({ + filePath: '/repo/README.md', + runtimeEnvironmentId: undefined + }), + options: { preview: true, focusEditor: true, suppressActiveRuntimeFallback: true } + }) ) }) }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts index 5ae97cf1248..8e64f0dae01 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -12,25 +12,12 @@ import { requireMatchingFileExplorerOperationRoute } from './file-explorer-operation-owner' import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' +import type { WorkspaceFileOpener } from '@/lib/workspace-file-open' type UseFileExplorerHandlersParams = { activeWorktreeId: string | null runtimeEnvironmentId?: string | null - openFile: ( - params: { - filePath: string - relativePath: string - worktreeId: string - language: string - mode: 'edit' - runtimeEnvironmentId?: string | null - }, - options?: { - preview?: boolean - suppressActiveRuntimeFallback?: boolean - focusEditor?: boolean - } - ) => void + openWorkspaceFile: WorkspaceFileOpener makePreviewFilePermanent: (filePath: string) => void toggleDir: (worktreeId: string, dirPath: string) => void canToggleDirectories?: boolean @@ -51,14 +38,11 @@ type UseFileExplorerHandlersReturn = { handleWheelCapture: (e: React.WheelEvent) => void } -type OpenFileParams = Parameters[0] -type OpenFileOptions = Parameters[1] - export async function activateFileExplorerNode(args: { node: TreeNode activeWorktreeId: string | null runtimeEnvironmentId?: string | null - openFile: (params: OpenFileParams, options?: OpenFileOptions) => void + openWorkspaceFile: WorkspaceFileOpener toggleDir: (worktreeId: string, dirPath: string) => void canToggleDirectories?: boolean loadDir: UseFileExplorerHandlersParams['loadDir'] @@ -69,7 +53,7 @@ export async function activateFileExplorerNode(args: { const { node, activeWorktreeId, - openFile, + openWorkspaceFile, toggleDir, canToggleDirectories = true, loadDir, @@ -139,8 +123,9 @@ export async function activateFileExplorerNode(args: { toast.error(getFileExplorerOwnerUnresolvedMessage()) return } - openFile( - { + openWorkspaceFile({ + kind: 'file', + file: { filePath: node.path, // Why: a file link out of the project opens by its absolute path, as a file the user named, // so it reads the same before and after a restart instead of being refused as a project file. @@ -150,7 +135,7 @@ export async function activateFileExplorerNode(args: { language: detectLanguage(node.name), mode: 'edit' }, - { + options: { preview: true, // Why: activating an Explorer file is a focus handoff even if the rich // editor finishes mounting after the row receives browser focus. @@ -159,13 +144,13 @@ export async function activateFileExplorerNode(args: { // encode "no runtime owner" via the fallback-suppression option. suppressActiveRuntimeFallback: fileRuntimeEnvironmentId === null } - ) + }) } export function useFileExplorerHandlers({ activeWorktreeId, runtimeEnvironmentId, - openFile, + openWorkspaceFile, makePreviewFilePermanent, toggleDir, canToggleDirectories = true, @@ -187,7 +172,7 @@ export function useFileExplorerHandlers({ node, activeWorktreeId, runtimeEnvironmentId, - openFile, + openWorkspaceFile, toggleDir, canToggleDirectories, loadDir, @@ -202,7 +187,7 @@ export function useFileExplorerHandlers({ canToggleDirectories, loadDir, markPathAsDirectory, - openFile, + openWorkspaceFile, statPath, toggleDir, setSelectedPath diff --git a/src/renderer/src/components/status-bar/workspace-space-open-change.test.ts b/src/renderer/src/components/status-bar/workspace-space-open-change.test.ts index 827ae0dad17..879d1c5d123 100644 --- a/src/renderer/src/components/status-bar/workspace-space-open-change.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-open-change.test.ts @@ -21,8 +21,8 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) vi.mock('@/store/selectors', () => ({ getWorktreeOnHostFromState: mocks.getWorktree })) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activate })) vi.mock('@/runtime/runtime-git-status-client', () => ({ getRuntimeGitStatus: mocks.gitStatus })) -vi.mock('../right-sidebar/source-control/listing/open-uncommitted-entry', () => ({ - openUncommittedEntry: mocks.openEntry +vi.mock('@/lib/workspace-file-open', () => ({ + openWorkspaceFile: mocks.openEntry })) vi.mock('sonner', () => ({ toast: { error: mocks.error } })) @@ -61,13 +61,14 @@ describe('Space change inspection', () => { providesInitialSurface: true, executionHostId: hostId }) - expect(mocks.openEntry).toHaveBeenCalledWith( - mocks.state, - 'same-id', - '/server/workspace', + expect(mocks.openEntry).toHaveBeenCalledWith({ + kind: 'uncommitted', + intent: 'diff', + worktreeId: 'same-id', + worktreePath: '/server/workspace', entry, - { preview: true, runtimeEnvironmentId } - ) + options: { preview: true, runtimeEnvironmentId } + }) } ) @@ -88,7 +89,7 @@ describe('Space change inspection', () => { const conflict = { ...entry, conflictKind: 'both_deleted', conflictStatus: 'unresolved' } mocks.gitStatus.mockResolvedValue({ entries: [conflict] }) await revealWorkspaceSpaceChange('same-id', 'local', entry) - expect(mocks.openEntry.mock.calls[0][3]).toBe(conflict) + expect(mocks.openEntry.mock.calls[0][0].entry).toBe(conflict) }) it('reports a stale change without navigating or opening a file', async () => { diff --git a/src/renderer/src/components/status-bar/workspace-space-open-change.ts b/src/renderer/src/components/status-bar/workspace-space-open-change.ts index 289e25ea3f5..7b3ae7ce788 100644 --- a/src/renderer/src/components/status-bar/workspace-space-open-change.ts +++ b/src/renderer/src/components/status-bar/workspace-space-open-change.ts @@ -9,7 +9,7 @@ import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client' import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target' -import { openUncommittedEntry } from '../right-sidebar/source-control/listing/open-uncommitted-entry' +import { openWorkspaceFile } from '@/lib/workspace-file-open' import { translate } from '@/i18n/i18n' export function openWorkspaceSpaceChange( @@ -77,9 +77,16 @@ export async function revealWorkspaceSpaceChange( if (activated === false) { return } - openUncommittedEntry(useAppStore.getState(), worktreeId, worktree.path, entry, { - preview: true, - runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null + openWorkspaceFile({ + kind: 'uncommitted', + intent: 'diff', + worktreeId, + worktreePath: worktree.path, + entry, + options: { + preview: true, + runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null + } }) } catch (error) { toast.error(translate('workspaceSpace.changes.openFailed', 'Could not open changes'), { diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts index 7755c1f1303..f1be7eed856 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts @@ -1,19 +1,16 @@ import { detectLanguage } from '@/lib/language-detect' import { toWorktreeRelativePath } from '@/lib/terminal-links' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' -import type { OpenFile } from '@/store/slices/editor' import { validateNewTabEntryAbsolutePath, type TabEntryLocalPlatform } from './tab-create-entry-path-validation' import type { statUserOpenedPath, UserOpenedPathStat } from '@/lib/user-opened-local-path' +import type { openWorkspaceFile } from '@/lib/workspace-file-open' type AbsoluteFileOperations = { assertAbsolutePathAllowed: () => void - openFile: ( - file: Omit, - options?: { preview?: boolean; targetGroupId?: string } - ) => void + openWorkspaceFile: typeof openWorkspaceFile statUserOpenedPath: typeof statUserOpenedPath } @@ -39,8 +36,9 @@ export async function openAbsoluteTabEntryFile(args: { } args.operations.assertAbsolutePathAllowed() - args.operations.openFile( - { + args.operations.openWorkspaceFile({ + kind: 'file', + file: { filePath, // Why: a project link out of the project keeps its absolute path, so it reads as user-named. relativePath: stat.escapesWorktree @@ -50,6 +48,6 @@ export async function openAbsoluteTabEntryFile(args: { language: detectLanguage(filePath), mode: 'edit' }, - { preview: false, targetGroupId: args.groupId } - ) + options: { preview: false, targetGroupId: args.groupId } + }) } diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index aedf821893e..868f22976ab 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -27,8 +27,7 @@ describe('openTabEntryWithOperations', () => { return { createRuntimePath: vi.fn().mockResolvedValue(undefined), openWorkspaceBrowserTab: vi.fn().mockResolvedValue(undefined), - openHtmlFilePreview: vi.fn(), - openFile: vi.fn(), + openWorkspaceFile: vi.fn(), statRuntimePath: vi.fn().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }), statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: false }), assertAbsolutePathAllowed: vi.fn(), @@ -45,12 +44,50 @@ describe('openTabEntryWithOperations', () => { fileList: readyFiles(['src/index.ts']), worktreeId: 'wt-1', groupId: 'group-1', - getFileContext: () => ({ worktreePath: '/repo', runtimeContext }), + getFileContext: () => ({ worktreePath: '/repo', runtimeContext, assertCurrent: () => {} }), allowAbsolutePaths: true, localPlatform: 'posix' as const, searchEngine: 'google' as const } + it.each(['report.html', '/repo/report.html'])( + 'requests the default file view for an existing HTML file: %s', + async (query) => { + const operations = makeOperations() + await openTabEntryWithOperations({ + ...baseArgs, + fileList: readyFiles(['report.html']), + query, + operations + }) + expect(operations.openWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'file', + file: expect.objectContaining({ filePath: '/repo/report.html' }), + options: { preview: false, targetGroupId: 'group-1' } + }) + ) + expect(operations.createRuntimePath).not.toHaveBeenCalled() + } + ) + + it('requests source for newly created HTML so it can be edited', async () => { + const operations = makeOperations() + await openTabEntryWithOperations({ ...baseArgs, query: 'new.html', operations }) + expect(operations.openWorkspaceFile).toHaveBeenCalledWith({ + kind: 'file', + intent: 'source', + file: { + filePath: '/repo/new.html', + relativePath: 'new.html', + worktreeId: 'wt-1', + language: 'html', + mode: 'edit' + }, + options: { preview: false, targetGroupId: 'group-1' } + }) + }) + it.each([true, false])( 'previews HTML file URLs without creating files (local paths: %s)', async (allowAbsolutePaths) => { @@ -64,7 +101,8 @@ describe('openTabEntryWithOperations', () => { query: 'file:///repo/docs/report%20%E4%B8%AD.html', operations }) - expect(operations.openHtmlFilePreview).toHaveBeenCalledWith({ + expect(operations.openWorkspaceFile).toHaveBeenCalledWith({ + kind: 'preview', filePath: '/repo/docs/report 中.html', worktreeId: 'wt-1', targetGroupId: 'group-1' @@ -83,7 +121,7 @@ describe('openTabEntryWithOperations', () => { await expect( openTabEntryWithOperations({ ...baseArgs, query: 'src/index.ts', operations }) ).rejects.toThrow('Cannot open a directory') - expect(operations.openFile).not.toHaveBeenCalled() + expect(operations.openWorkspaceFile).not.toHaveBeenCalled() }) it('creates new files and opens them in the target group', async () => { @@ -101,13 +139,16 @@ describe('openTabEntryWithOperations', () => { '/repo/docs/new.md', 'file' ) - expect(operations.openFile).toHaveBeenCalledWith( + expect(operations.openWorkspaceFile).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '/repo/docs/new.md', - relativePath: 'docs/new.md', - worktreeId: 'wt-1' - }), - { preview: false, targetGroupId: 'group-1' } + kind: 'file', + file: expect.objectContaining({ + filePath: '/repo/docs/new.md', + relativePath: 'docs/new.md', + worktreeId: 'wt-1' + }), + options: { preview: false, targetGroupId: 'group-1' } + }) ) }) @@ -127,9 +168,12 @@ describe('openTabEntryWithOperations', () => { }) expect(operations.createRuntimePath).not.toHaveBeenCalled() - expect(operations.openFile).toHaveBeenCalledWith( - expect.objectContaining({ relativePath: 'README.md' }), - { preview: false, targetGroupId: 'group-1' } + expect(operations.openWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'file', + file: expect.objectContaining({ relativePath: 'README.md' }), + options: { preview: false, targetGroupId: 'group-1' } + }) ) }) @@ -160,12 +204,15 @@ describe('openTabEntryWithOperations', () => { '/repo/.tmp/direct-entry-validation/created.md', 'file' ) - expect(operations.openFile).toHaveBeenCalledWith( + expect(operations.openWorkspaceFile).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '/repo/.tmp/direct-entry-validation/created.md', - relativePath: '.tmp/direct-entry-validation/created.md' - }), - { preview: false, targetGroupId: 'group-1' } + kind: 'file', + file: expect.objectContaining({ + filePath: '/repo/.tmp/direct-entry-validation/created.md', + relativePath: '.tmp/direct-entry-validation/created.md' + }), + options: { preview: false, targetGroupId: 'group-1' } + }) ) }) @@ -189,7 +236,7 @@ describe('openTabEntryWithOperations', () => { '/repo/docs/new.md', 'file' ) - expect(operations.openFile).toHaveBeenCalled() + expect(operations.openWorkspaceFile).toHaveBeenCalled() }) it('rejects invalid new file paths before creating parent directories', async () => { @@ -200,7 +247,7 @@ describe('openTabEntryWithOperations', () => { ).rejects.toThrow('File paths cannot contain . or .. segments.') expect(operations.createRuntimePath).not.toHaveBeenCalled() - expect(operations.openFile).not.toHaveBeenCalled() + expect(operations.openWorkspaceFile).not.toHaveBeenCalled() }) it('stats and opens when create loses an EEXIST race to a file', async () => { @@ -214,7 +261,7 @@ describe('openTabEntryWithOperations', () => { await openTabEntryWithOperations({ ...baseArgs, query: 'docs/race.md', operations }) expect(operations.statRuntimePath).toHaveBeenCalledWith(runtimeContext, '/repo/docs/race.md') - expect(operations.openFile).toHaveBeenCalled() + expect(operations.openWorkspaceFile).toHaveBeenCalled() }) it('routes URL classifications through the workspace browser opener', async () => { @@ -302,13 +349,16 @@ describe('openTabEntryWithOperations', () => { }) expect(operations.statUserOpenedPath).toHaveBeenCalledWith(runtimeContext, '/tmp/notes.md') - expect(operations.openFile).toHaveBeenCalledWith( + expect(operations.openWorkspaceFile).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '/tmp/notes.md', - relativePath: '/tmp/notes.md', - worktreeId: 'wt-1' - }), - { preview: false, targetGroupId: 'group-1' } + kind: 'file', + file: expect.objectContaining({ + filePath: '/tmp/notes.md', + relativePath: '/tmp/notes.md', + worktreeId: 'wt-1' + }), + options: { preview: false, targetGroupId: 'group-1' } + }) ) }) @@ -322,12 +372,15 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.openFile).toHaveBeenCalledWith( + expect(operations.openWorkspaceFile).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '/repo/src/index.ts', - relativePath: 'src/index.ts' - }), - { preview: false, targetGroupId: 'group-1' } + kind: 'file', + file: expect.objectContaining({ + filePath: '/repo/src/index.ts', + relativePath: 'src/index.ts' + }), + options: { preview: false, targetGroupId: 'group-1' } + }) ) }) @@ -343,12 +396,15 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.openFile).toHaveBeenCalledWith( + expect(operations.openWorkspaceFile).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '/repo/docs-link/secret.md', - relativePath: '/repo/docs-link/secret.md' - }), - { preview: false, targetGroupId: 'group-1' } + kind: 'file', + file: expect.objectContaining({ + filePath: '/repo/docs-link/secret.md', + relativePath: '/repo/docs-link/secret.md' + }), + options: { preview: false, targetGroupId: 'group-1' } + }) ) }) @@ -367,7 +423,7 @@ describe('openTabEntryWithOperations', () => { expect(operations.statUserOpenedPath).not.toHaveBeenCalled() expect(operations.createRuntimePath).not.toHaveBeenCalled() - expect(operations.openFile).not.toHaveBeenCalled() + expect(operations.openWorkspaceFile).not.toHaveBeenCalled() }) it('rejects Windows path syntax before touching a native POSIX path', async () => { @@ -383,7 +439,7 @@ describe('openTabEntryWithOperations', () => { ).rejects.toThrow('Enter an absolute path for this computer.') expect(operations.statUserOpenedPath).not.toHaveBeenCalled() - expect(operations.openFile).not.toHaveBeenCalled() + expect(operations.openWorkspaceFile).not.toHaveBeenCalled() }) it('normalizes and opens Windows drive paths on Windows', async () => { @@ -392,7 +448,11 @@ describe('openTabEntryWithOperations', () => { await openTabEntryWithOperations({ ...baseArgs, localPlatform: 'windows', - getFileContext: () => ({ worktreePath: 'C:/repo', runtimeContext }), + getFileContext: () => ({ + worktreePath: 'C:/repo', + runtimeContext, + assertCurrent: () => {} + }), classification: { kind: 'absolute-file', filePath: 'C:\\tmp\\notes.md' }, query: 'C:\\tmp\\notes.md', operations @@ -432,6 +492,6 @@ describe('openTabEntryWithOperations', () => { releaseStat?.() await rejection - expect(operations.openFile).not.toHaveBeenCalled() + expect(operations.openWorkspaceFile).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts index 30c7cf02690..9d32a8fae6b 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts @@ -5,7 +5,6 @@ import { createRuntimePath } from '@/runtime/runtime-file-mutation-client' import { statRuntimePath } from '@/runtime/runtime-file-metadata-client' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { useAppStore } from '@/store' -import type { OpenFile } from '@/store/slices/editor' import { buildSearchUrl, DEFAULT_SEARCH_ENGINE, @@ -47,7 +46,11 @@ export { isTabEntryAbsolutePathAllowed } from './tab-create-entry-local-path' import { statUserOpenedPath } from '@/lib/user-opened-local-path' -import { openHtmlFilePreview } from '@/lib/open-html-file-preview' +import { openWorkspaceFile } from '@/lib/workspace-file-open' +import { + assertEditorFileOperationCurrent, + captureEditorFileOperationProvenance +} from '@/lib/editor-file-operation-owner' export type TabCreateEntryArgs = { classification?: TabEntryActionClassification @@ -60,11 +63,7 @@ export type TabCreateEntryArgs = { export type TabEntryOperations = { createRuntimePath: typeof createRuntimePath openWorkspaceBrowserTab: typeof openWorkspaceBrowserTab - openHtmlFilePreview: typeof openHtmlFilePreview - openFile: ( - file: Omit, - options?: { preview?: boolean; targetGroupId?: string } - ) => void + openWorkspaceFile: typeof openWorkspaceFile statRuntimePath: typeof statRuntimePath statUserOpenedPath: typeof statUserOpenedPath assertAbsolutePathAllowed: () => void @@ -75,7 +74,11 @@ type OpenTabEntryWithOperationsArgs = { fileList: RuntimeFileListState worktreeId: string groupId: string - getFileContext: () => { worktreePath: string; runtimeContext: RuntimeFileOperationArgs } + getFileContext: () => { + worktreePath: string + runtimeContext: RuntimeFileOperationArgs + assertCurrent: () => void + } allowAbsolutePaths: boolean localPlatform: TabEntryLocalPlatform searchEngine: SearchEngine @@ -116,49 +119,15 @@ async function createParentDirectoriesForNewFile(args: { } } -async function openExistingFile(args: { - context: RuntimeFileOperationArgs - groupId: string - operations: TabEntryOperations - relativePath: string - worktreeId: string - worktreePath: string -}): Promise { - const filePath = joinPath(args.worktreePath, args.relativePath) - let stat: Awaited> - try { - stat = await args.operations.statRuntimePath(args.context, filePath) - } catch { - throw new Error(`File no longer exists: ${args.relativePath}`) - } - if (stat.isDirectory) { - throw new Error(`Cannot open a directory: ${args.relativePath}`) - } - args.operations.openFile( - { - filePath, - relativePath: args.relativePath, - worktreeId: args.worktreeId, - language: detectLanguage(args.relativePath), - mode: 'edit' - }, - { preview: false, targetGroupId: args.groupId } - ) -} - type NetworkTabEntryClassification = Extract< TabEntryActionClassification, { kind: 'explicit-url' | 'host-url' | 'search' } > function isNetworkTabEntry( - classification: TabEntryActionClassification -): classification is NetworkTabEntryClassification { - return ( - classification.kind === 'explicit-url' || - classification.kind === 'host-url' || - classification.kind === 'search' - ) + entry: TabEntryActionClassification +): entry is NetworkTabEntryClassification { + return entry.kind === 'explicit-url' || entry.kind === 'host-url' || entry.kind === 'search' } function getNetworkTabRequest( @@ -198,7 +167,8 @@ export async function openTabEntryWithOperations({ } if (classification.kind === 'html-file-url') { - operations.openHtmlFilePreview({ + operations.openWorkspaceFile({ + kind: 'preview', filePath: classification.filePath, worktreeId, targetGroupId: groupId @@ -213,7 +183,7 @@ export async function openTabEntryWithOperations({ return } - const { worktreePath, runtimeContext } = getFileContext() + const { worktreePath, runtimeContext, assertCurrent } = getFileContext() if (classification.kind === 'absolute-file') { if (!allowAbsolutePaths) { @@ -231,39 +201,43 @@ export async function openTabEntryWithOperations({ return } - if (classification.kind === 'existing-file') { - await openExistingFile({ - context: runtimeContext, - groupId, - operations, - relativePath: classification.relativePath, - worktreeId, - worktreePath - }) - return - } - const filePath = joinPath(worktreePath, classification.relativePath) - try { - await createParentDirectoriesForNewFile({ - context: runtimeContext, - operations, - relativePath: classification.relativePath, - worktreePath - }) - await operations.createRuntimePath(runtimeContext, filePath, 'file') - } catch (error) { - if (!isExistsError(error)) { - throw error + if (classification.kind === 'new-file') { + try { + await createParentDirectoriesForNewFile({ + context: runtimeContext, + operations, + relativePath: classification.relativePath, + worktreePath + }) + await operations.createRuntimePath(runtimeContext, filePath, 'file') + } catch (error) { + if (!isExistsError(error)) { + throw error + } } } - await openExistingFile({ - context: runtimeContext, - groupId, - operations, - relativePath: classification.relativePath, - worktreeId, - worktreePath + let stat: Awaited> + try { + stat = await operations.statRuntimePath(runtimeContext, filePath) + } catch { + throw new Error(`File no longer exists: ${classification.relativePath}`) + } + assertCurrent() + if (stat.isDirectory) { + throw new Error(`Cannot open a directory: ${classification.relativePath}`) + } + operations.openWorkspaceFile({ + kind: 'file', + intent: classification.kind === 'new-file' ? 'source' : 'default', + file: { + filePath, + relativePath: classification.relativePath, + worktreeId, + language: detectLanguage(classification.relativePath), + mode: 'edit' + }, + options: { preview: false, targetGroupId: groupId } }) } @@ -282,9 +256,18 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise { if (!worktree) { throw new Error('No active worktree.') } + const provenance = captureEditorFileOperationProvenance( + state, + args.worktreeId, + undefined, + false + ) return { worktreePath: worktree.path, - runtimeContext: getTabEntryFileOperationContext(state, args.worktreeId, worktree.path) + runtimeContext: getTabEntryFileOperationContext(state, args.worktreeId, worktree.path), + assertCurrent: () => { + assertEditorFileOperationCurrent(useAppStore.getState(), args.worktreeId, provenance) + } } }, allowAbsolutePaths, @@ -295,8 +278,7 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise { operations: { createRuntimePath, openWorkspaceBrowserTab, - openHtmlFilePreview, - openFile: state.openFile, + openWorkspaceFile, statRuntimePath, statUserOpenedPath, assertAbsolutePathAllowed: () => { diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts index 5f4a887574a..e5678e913de 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.test.ts @@ -4,6 +4,9 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' import type { Repo } from '../../../../shared/repo-types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import * as metadata from '@/runtime/runtime-file-metadata-client' +import { openTabBarEntry } from './tab-create-entry-action' import { createTabEntryAllowAbsolutePathsSelector, getTabEntryAllowAbsolutePaths, @@ -60,9 +63,72 @@ function makeProjectGroup(overrides: Partial = {}): ProjectGroup { describe('getTabEntryAllowAbsolutePaths', () => { afterEach(() => { + vi.restoreAllMocks() useAppStore.setState(initialState, true) }) + it('refuses an HTML open if its workspace changes hosts during the file check', async () => { + const worktree = makeWorktree({ + id: localWorktreeId, + repoId: 'repo-local', + path: '/Users/me/repo', + hostId: 'local' + }) + useAppStore.setState({ + repos: [makeRepo({ id: 'repo-local', executionHostId: 'local' })], + worktreesByRepo: { 'repo-local': [worktree] }, + runtimeEnvironmentCatalogHydrated: true + }) + vi.spyOn(metadata, 'statRuntimePath').mockImplementationOnce(async () => { + useAppStore.setState({ + repos: [makeRepo({ id: 'repo-local', executionHostId: 'runtime:hub-a' })], + worktreesByRepo: { + 'repo-local': [ + { ...worktree, hostId: 'runtime:hub-a', runtimeOwnerEnvironmentId: 'hub-a' } + ] + } + }) + return { size: 1, isDirectory: false, mtime: 1 } + }) + + await expect( + openTabBarEntry({ + query: 'report.html', + worktreeId: localWorktreeId, + groupId: 'group-local', + fileList: { files: ['report.html'], loading: false, loadError: null } + }) + ).rejects.toThrow("Couldn't verify which host owns this file") + expect(useAppStore.getState().browserTabsByWorktree[localWorktreeId] ?? []).toHaveLength(0) + expect(useAppStore.getState().openFiles).toHaveLength(0) + }) + + it('previews an existing HTML file in a local folder workspace', async () => { + const folder = makeFolderWorkspace() + const worktreeId = folderWorkspaceKey(folder.id) + useAppStore.setState({ + folderWorkspaces: [folder], + projectGroups: [makeProjectGroup()], + repos: [] + }) + vi.spyOn(metadata, 'statRuntimePath').mockResolvedValue({ + size: 1, + isDirectory: false, + mtime: 1 + }) + + await openTabBarEntry({ + query: 'report.html', + worktreeId, + groupId: 'group-local', + fileList: { files: ['report.html'], loading: false, loadError: null } + }) + expect(useAppStore.getState().browserTabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ url: 'file:///Users/me/folder/report.html' }) + ]) + expect(useAppStore.getState().openFiles).toHaveLength(0) + }) + it('allows absolute paths for a known local worktree', () => { useAppStore.setState({ repos: [makeRepo({ id: 'repo-local' })], diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts index f1c443e2647..c7777802288 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts @@ -2,7 +2,7 @@ import { expect, it, vi } from 'vitest' import { openTabBarEntry } from './tab-create-entry-action' const preview = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/open-html-file-preview', () => ({ openHtmlFilePreview: preview })) +vi.mock('@/lib/workspace-file-open', () => ({ openWorkspaceFile: preview })) vi.mock('@/lib/editor-file-operation-owner', () => ({ getEditorFileOperationContext: () => { throw new Error('A preview must not require file mutation authorization') @@ -29,6 +29,7 @@ it.each([true, false])( }) expect(preview).toHaveBeenCalledTimes(1) expect(preview).toHaveBeenCalledWith({ + kind: 'preview', filePath: '/repo/report.html', worktreeId: 'remote', targetGroupId: 'group-2' diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 3ac5e36d11f..761c0bd218e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -1,12 +1,11 @@ -import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links' -import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview' +import { openWorkspaceFile } from '@/lib/workspace-file-open' import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' import { canClientOsOpenWorkspaceFile } from '@/lib/workspace-file-host-routing' import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client' import { useAppStore } from '@/store' -import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' import { getTerminalFileContext, @@ -29,7 +28,7 @@ import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-wor import { captureEditorOpenLinkIntent } from '@/store/slices/editor/tabs/editor-open-target-group' import { cancelPendingEditorLineRevealFrames } from '@/store/slices/editor/focus/editor-focus-reveal' import { showOpenInGroupRefusedToast } from './terminal-file-open-in-group-toast' -import { isExplicitEditorScopeKept, openLinkedFileInEditor } from './terminal-file-link-editor-open' +import { isExplicitEditorScopeKept } from '@/lib/editor-file-link-open' export { getTerminalFileContext, @@ -62,28 +61,6 @@ type TerminalFileOpenDeps = { openInSourceGroup?: boolean } -export function isHtmlFilePath(filePath: string): boolean { - return /\.html?$/i.test(filePath) -} - -function openHtmlFileInBrowser(filePath: string, worktreeId: string, clientLocal: boolean): void { - const store = useAppStore.getState() - if (worktreeId) { - // Why: following an HTML file link changes which worktree is foregrounded, - // so it must record a history visit before opening the browser tab — but the - // browser tab is the surface, so an emptied workspace must not gain a shell. - activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) - } - const fileUrl = absolutePathToFileUri(filePath) - const title = filePath.split(/[/\\]/).pop() ?? filePath - store.createBrowserTab(worktreeId, fileUrl, { - title, - activate: true, - // Why: the file is on this computer, so the workspace's paired-server runtime must not adopt the page. - ...(clientLocal ? { browserRuntimeEnvironmentId: null } : {}) - }) -} - export function shouldOpenTerminalFileWithSystemDefault(fileContext: TerminalFileContext): boolean { // Why: an unresolved owner has no connectionId either, which would otherwise read as local. return fileContext.sourceHostResolved && canClientOsOpenWorkspaceFile(fileContext) @@ -236,30 +213,6 @@ export function openDetectedFilePath( return } - // Why: local HTML files render in Orca's browser for ordinary Cmd/Ctrl-click, - // and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default. - if (isHtmlFilePath(mappedFilePath) && hostGrantId === null) { - if (canOpenWithSystemDefault) { - // Why: Orca's browser has no group placement in v1. - if (refuseExplicitNonEditorRoute()) { - return - } - openHtmlFileInBrowser(mappedFilePath, worktreeId, clientLocal) - return - } - // Why: the same gesture renders remote HTML too, through the doc preview; only an - // unsupported plan (e.g. a paired doc outside the worktree) falls back to source. - const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath) - if (plan.status === 'doc-preview') { - if (refuseExplicitNonEditorRoute()) { - return - } - activateAndRevealWorktree(worktreeId, { providesInitialSurface: true }) - openFileInBrowserTab({ filePath: mappedFilePath, worktreeId }) - return - } - } - const store = useAppStore.getState() let targetWorktreeId = worktreeId const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) ?? sourceRuntimeOwnerId @@ -301,17 +254,11 @@ export function openDetectedFilePath( return } - if (targetWorktreeId) { - // Why: the route may name a folder-workspace key, and the same worktree id can exist - // on several hosts — dispatch by workspace shape and keep the resolved host. - activateAndRevealWorkspace(targetWorktreeId, { - providesInitialSurface: true, - executionHostId: targetExecutionHostId - }) - } - - const opened = openLinkedFileInEditor( - { + const opened = openWorkspaceFile({ + kind: 'link', + executionHostId: targetExecutionHostId, + clientLocal, + file: { filePath: mappedFilePath, relativePath, worktreeId: targetWorktreeId || '', @@ -329,10 +276,10 @@ export function openDetectedFilePath( : {}) }, linkIntent, - { line, column }, - () => requestId === latestOpenDetectedFilePathRequestId - ) - if (!opened) { + position: { line, column }, + isCurrentRequest: () => requestId === latestOpenDetectedFilePathRequestId + }) + if (opened.surface === 'refused') { refuseExplicitNonEditorRoute() } })() diff --git a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts index 79546d1a4a8..91b89e8a09a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { openDetectedFilePath } from './terminal-link-handlers' import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' import { @@ -67,8 +67,9 @@ describe('handleOscLink', () => { expect(openFilePathMock).not.toHaveBeenCalled() // Why: the browser tab is the surface — activation must not re-seed a shell into a // workspace whose last terminal the user closed. - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true + expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { + providesInitialSurface: true, + executionHostId: 'local' }) }) @@ -88,6 +89,20 @@ describe('handleOscLink', () => { expect(openFilePathMock).not.toHaveBeenCalled() }) + it('opens an HTML line link in source and reveals the requested line', async () => { + openDetectedFilePath('/tmp/report.html', 12, 3, deps) + await flushAsyncWork() + await flushDoubleRaf() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/report.html', mode: 'edit' }), + { forceContentReload: true } + ) + expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith( + expect.objectContaining({ line: 12, column: 3, filePath: '/tmp/report.html' }) + ) + }) + it('opens local file paths in Orca and reveals default column 1 with the platform modifier', async () => { setPlatform('Macintosh') diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts index e853288edc2..57158e78b6a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts @@ -1,6 +1,7 @@ import { vi, type Mock } from 'vitest' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { BrowserPage } from '../../../../shared/browser-workspace-types' export type TerminalLinkStoreSettings = { openLinksInApp?: boolean @@ -35,6 +36,17 @@ export type TerminalLinkStoreState = { }[] > folderWorkspaces: [] + repos: { + id: string + displayName: string + connectionId?: string | null + executionHostId?: ExecutionHostId + }[] + browserTabsByWorktree: Record + browserPagesByWorkspace: Record + getKnownWorktreeById: ( + id: string + ) => TerminalLinkStoreState['worktreesByRepo'][string][number] | undefined } export type TerminalLinkTestDoubles = { @@ -109,7 +121,14 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles { unifiedTabsByWorktree: {}, groupsByWorktree: {}, worktreesByRepo: {}, - folderWorkspaces: [] as [] + folderWorkspaces: [], + repos: [], + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + getKnownWorktreeById: (id) => + Object.values(storeState.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === id) } return { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts index 17b60add654..e953de7e405 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts @@ -60,7 +60,14 @@ export function installTerminalLinkTestEnvironment(doubles: TerminalLinkTestDoub storeState.openFiles = [] storeState.unifiedTabsByWorktree = {} storeState.groupsByWorktree = {} - storeState.worktreesByRepo = {} + storeState.worktreesByRepo = { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/tmp', hostId: 'local' }] + } + storeState.repos = [ + { id: 'repo-1', displayName: 'Repo', connectionId: null, executionHostId: 'local' } + ] + storeState.browserTabsByWorktree = {} + storeState.browserPagesByWorkspace = {} registerHttpLinkStoreAccessor(() => storeState) vi.stubGlobal('window', { dispatchEvent: vi.fn(), 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 24741756e12..43928d6959c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -8,10 +8,8 @@ import { dedupeLogicalLines, openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' -import { - isHtmlFilePath, - shouldOpenTerminalFileWithSystemDefault -} from './terminal-file-open-routing' +import { isHtmlFilePath } from '../../../../shared/html-file-url' +import { shouldOpenTerminalFileWithSystemDefault } from './terminal-file-open-routing' import { fileLinkTargetExists, resolveFileLinkTarget } from './terminal-file-link-target' import { buildHardWrappedPathLogicalLineCandidates, diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts index a4da27e7050..8a839570cdf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-beside-source.test.ts @@ -32,10 +32,6 @@ vi.mock('@/store', () => ({ vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) -vi.mock('@/lib/language-detect', () => ({ - detectLanguage: () => 'plaintext' -})) - vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace: vi.fn(), activateAndRevealWorktree: vi.fn() @@ -110,7 +106,7 @@ describe('terminal file links carry their source tab', () => { ) }) - it('refuses an explicit request that resolves to Orca browser HTML', async () => { + it('opens HTML source in the explicitly requested source group', async () => { setPlatform('Macintosh') seedSourceTab() @@ -118,8 +114,17 @@ describe('terminal file links carry their source tab', () => { await flushAsyncWork() expect(createBrowserTabMock).not.toHaveBeenCalled() - expect(openFileForLinkMock).not.toHaveBeenCalled() - expect(toastErrorMock).toHaveBeenCalledTimes(1) + expect(openFileForLinkMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/report.html', language: 'html', mode: 'edit' }), + { forceContentReload: true }, + expect.objectContaining({ + workspaceId: 'wt-1', + hostId: null, + targetGroupId: 'group-a' + }), + expect.any(Function) + ) + expect(toastErrorMock).not.toHaveBeenCalled() }) it('refuses a closed explicit group before activating its workspace', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts index 524f04b119e..2bc01752026 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { openDetectedFilePath } from './terminal-link-handlers' import { getConnectionId } from '@/lib/connection-context' import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' import { @@ -47,7 +47,7 @@ vi.mock('./terminal-remote-file-download-open', () => ({ vi.mock('@/lib/file-preview', () => ({ getWorkspaceFilePreviewPlan: vi.fn(() => ({ status: 'doc-preview' })), - openFileInBrowserTab: vi.fn() + openFileInBrowserTab: vi.fn(() => ({ status: 'doc-preview' })) })) installTerminalLinkTestEnvironment(doubles) @@ -254,8 +254,9 @@ describe('handleOscLink', () => { }) // Why: the preview tab is the surface — activation must not re-seed a shell into a // workspace whose last terminal the user closed. - expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - providesInitialSurface: true + expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { + providesInitialSurface: true, + executionHostId: 'ssh:ssh-1' }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-editor-open.ts b/src/renderer/src/lib/editor-file-link-open.ts similarity index 97% rename from src/renderer/src/components/terminal-pane/terminal-file-link-editor-open.ts rename to src/renderer/src/lib/editor-file-link-open.ts index 3e7663ede34..369216519c1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-editor-open.ts +++ b/src/renderer/src/lib/editor-file-link-open.ts @@ -7,7 +7,7 @@ import type { EditorOpenLinkIntent, EditorOpenResult } from '@/store/slices/editor/types/editor-open-link-intent' -import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' /** An explicit group request may only end in an editor in the scope it was captured from. */ export function isExplicitEditorScopeKept( diff --git a/src/renderer/src/lib/file-preview-tab.ts b/src/renderer/src/lib/file-preview-tab.ts new file mode 100644 index 00000000000..2fc8986829e --- /dev/null +++ b/src/renderer/src/lib/file-preview-tab.ts @@ -0,0 +1,69 @@ +import { basename } from '@/lib/path' +import { activateBrowserWorkspaceTab } from '@/lib/browser-workspace-tab-activation' +import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner' +import type { AppState } from '@/store/types' +import type { CreateBrowserTabOptions } from '@/store/slices/browser/browser-slice-contract' +import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location' +import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types' +import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' +import { findWorkspaceShowingPage } from './workspace-file-preview-page' +import { getBrowserPageRuntimeEnvironmentId } from '@/components/browser-pane/describe-page/browser-page-url-display' +import { refreshLocalHtmlPreview } from '@/components/browser-pane/navigate/local-html-preview-freshness' +import { webviewRegistry } from '@/components/browser-pane/host-guest/webview-registry' +import type { WorkspaceFilePreviewPlan } from './file-preview' + +export function openFilePreviewTab( + state: AppState, + params: { filePath: string; worktreeId: string; activate: boolean; clientLocal?: boolean }, + plan: Exclude, + placement?: Pick +): void { + const docLocation: BrowserPageDocLocation | null = + plan.status === 'doc-preview' + ? { kind: 'workspace-doc', worktreeId: params.worktreeId, filePath: params.filePath } + : null + const inferredRuntimeEnvironmentId = runtimeEnvironmentIdUnlessTwin(state, params.worktreeId) + const previewRuntimeEnvironmentId = params.clientLocal ? null : inferredRuntimeEnvironmentId + const existing = findWorkspaceShowingPage( + state, + params.worktreeId, + (page) => + plan.status === 'browser-tab' + ? !page.docLocation && + page.url === plan.url && + getBrowserPageRuntimeEnvironmentId(page, inferredRuntimeEnvironmentId) === + previewRuntimeEnvironmentId + : browserPageDocLocationsEqual(page.docLocation ?? null, docLocation), + placement?.targetGroupId + ) + if (existing) { + const webview = plan.status === 'browser-tab' ? webviewRegistry.get(existing.pageId) : null + if (webview) { + void refreshLocalHtmlPreview(webview, params.filePath) + } + if ( + !params.activate || + !activateBrowserWorkspaceTab({ + worktreeId: params.worktreeId, + ...existing + }) + ) { + state.setActiveBrowserTab(existing.workspaceId) + state.setActiveBrowserPage(existing.workspaceId, existing.pageId) + } + return + } + state.createBrowserTab( + params.worktreeId, + plan.status === 'browser-tab' ? plan.url : ORCA_BROWSER_BLANK_URL, + { + title: + plan.status === 'browser-tab' ? plan.title : basename(params.filePath) || params.filePath, + ...placement, + // Document grants belong to this desktop even when the workspace is remote. + ...(docLocation ? { docLocation, browserRuntimeEnvironmentId: null } : {}), + ...(params.clientLocal ? { browserRuntimeEnvironmentId: null } : {}), + activate: params.activate + } + ) +} diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index 4d85c691553..fb5e2b3fef8 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -76,7 +76,15 @@ vi.mock('@/store', () => ({ ...(mocks.twin ? localAndSshTwinRows('repo-1', 'wt-1') : { - repos: [{ id: 'repo-1', connectionId: mocks.connectionId }], + repos: [ + { + id: 'repo-1', + connectionId: mocks.connectionId, + ...(mocks.environmentId + ? { executionHostId: `runtime:${mocks.environmentId}` } + : {}) + } + ], worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } }) }) @@ -124,6 +132,110 @@ function mockOpenDocument(filePath: string): void { } describe('openFileInBrowserTab', () => { + it.each(['remote-host', undefined])( + 'does not reuse a server-owned file URL for a client-local open: %s', + (browserRuntimeEnvironmentId) => { + mocks.environmentId = 'remote-host' + mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-remote' }] } + mocks.browserPagesByWorkspace = { + 'browser-remote': [ + { id: 'page-remote', url: 'file:///tmp/report.html', browserRuntimeEnvironmentId } + ] + } + + openFileInBrowserTab({ + filePath: '/tmp/report.html', + worktreeId: 'wt-1', + clientLocal: true + }) + + expect(mocks.createBrowserTab).toHaveBeenCalledWith('wt-1', 'file:///tmp/report.html', { + title: 'report.html', + targetGroupId: undefined, + browserRuntimeEnvironmentId: null, + activate: true + }) + expect(mocks.setActiveBrowserPage).not.toHaveBeenCalled() + } + ) + + it('reuses an explicitly local page in a paired workspace', () => { + mocks.environmentId = 'remote-host' + mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-local' }] } + mocks.browserPagesByWorkspace = { + 'browser-local': [ + { id: 'page-local', url: 'file:///tmp/report.html', browserRuntimeEnvironmentId: null } + ] + } + + openFileInBrowserTab({ + filePath: '/tmp/report.html', + worktreeId: 'wt-1', + clientLocal: true + }) + + expect(mocks.setActiveBrowserPage).toHaveBeenCalledWith('browser-local', 'page-local') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('does not reuse a remotely pinned page in a local workspace', () => { + mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-remote' }] } + mocks.browserPagesByWorkspace = { + 'browser-remote': [ + { + id: 'page-remote', + url: 'file:///tmp/report.html', + browserRuntimeEnvironmentId: 'remote-host' + } + ] + } + + openFileInBrowserTab({ filePath: '/tmp/report.html', worktreeId: 'wt-1' }) + + expect(mocks.createBrowserTab).toHaveBeenCalledOnce() + expect(mocks.setActiveBrowserPage).not.toHaveBeenCalled() + }) + + it.each([undefined, 'group-2', 'group-3'])( + 'reuses a local file on an inactive page within the requested group: %s', + (targetGroupId) => { + mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-9' }] } + mocks.browserPagesByWorkspace = { + 'browser-9': [ + { id: 'page-web', url: 'https://example.com' }, + { id: 'page-html', url: 'file:///repo/report%20one.html' } + ] + } + mocks.unifiedTabsByWorktree = { + 'wt-1': [ + { + id: 'tab-html', + worktreeId: 'wt-1', + contentType: 'browser', + entityId: 'browser-9', + groupId: 'group-2' + } + ] + } + + openFileInBrowserTab({ filePath: '/repo/report one.html', worktreeId: 'wt-1', targetGroupId }) + + if (targetGroupId === 'group-3') { + expect(mocks.createBrowserTab).toHaveBeenCalledWith( + 'wt-1', + 'file:///repo/report%20one.html', + { title: 'report one.html', activate: true, targetGroupId } + ) + expect(mocks.focusGroup).not.toHaveBeenCalled() + return + } + + expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2') + expect(mocks.activateTab).toHaveBeenCalledWith('tab-html', { worktreeId: 'wt-1' }) + expect(mocks.setActiveBrowserPage).toHaveBeenCalledWith('browser-9', 'page-html') + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + } + ) it('reuses a document on an inactive page instead of creating a duplicate preview', () => { mocks.connectionId = 'ssh-1' mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-9', docLocation: null }] } @@ -149,6 +261,10 @@ describe('openFileInBrowserTab', () => { }) it('keeps a remote document preview in the requested group', () => { mocks.environmentId = 'env-1' + mockOpenDocument('/srv/repo/docs/report.html') + mocks.unifiedTabsByWorktree = { + 'wt-1': [{ contentType: 'browser', entityId: 'browser-9', groupId: 'group-1' }] + } openFileInBrowserTab({ filePath: '/srv/repo/docs/report.html', worktreeId: 'wt-1', @@ -329,7 +445,11 @@ describe('openFileInBrowserTab', () => { ) }) - it('creates local side previews in an activated right-hand split', () => { + it('creates a local side preview in a right-hand split even when the file is already open', () => { + mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-9' }] } + mocks.browserPagesByWorkspace = { + 'browser-9': [{ id: 'page-html', url: 'file:///tmp/example.html' }] + } openFilePreviewToSide({ language: 'html', filePath: '/tmp/example.html', diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index a77d45e6289..8735fc98d94 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -18,14 +18,14 @@ import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { findSiblingGroupId } from '@/store/slices/tabs' -import type { CreateBrowserTabOptions } from '@/store/slices/browser/browser-slice-contract' import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location' import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types' import { findPage } from '@/store/slices/browser-page-records' import type { BrowserPageConversionLeg } from '@/store/slices/browser-page-conversion' -import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' import { chatScratchFileAddress } from '@/runtime/runtime-chat-scratch-file-address' import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { findWorkspaceShowingPage } from './workspace-file-preview-page' +import { openFilePreviewTab } from './file-preview-tab' export type PreviewableLanguage = 'html' @@ -51,11 +51,31 @@ export type WorkspaceFilePreviewPlan = | { status: 'doc-preview' } | { status: 'unsupported'; message: string; reason: 'no-channel' | 'outside-worktree' } -export function getWorkspaceFilePreviewPlan( +function getLocalFilePreviewPlan( state: AppState, worktreeId: string, filePath: string ): WorkspaceFilePreviewPlan { + const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] + if (availability.state !== 'enabled') { + return { status: 'unsupported', message: availability.reason, reason: 'no-channel' } + } + return { + status: 'browser-tab', + url: absolutePathToFileUri(filePath), + title: basename(filePath) || filePath + } +} + +export function getWorkspaceFilePreviewPlan( + state: AppState, + worktreeId: string, + filePath: string, + clientLocal = false +): WorkspaceFilePreviewPlan { + if (clientLocal) { + return getLocalFilePreviewPlan(state, worktreeId, filePath) + } // Why: a twin reads as "no server" below, which would open this machine's copy of the path. if (isTwinWorkspace(state, worktreeId)) { return { @@ -93,15 +113,7 @@ export function getWorkspaceFilePreviewPlan( } return webClientDocPreviewRefusal() ?? { status: 'doc-preview' } } - const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] - if (availability.state !== 'enabled') { - return { status: 'unsupported', message: availability.reason, reason: 'no-channel' } - } - return { - status: 'browser-tab', - url: absolutePathToFileUri(filePath), - title: basename(filePath) || filePath - } + return getLocalFilePreviewPlan(state, worktreeId, filePath) } export function canShowWorkspaceFileBrowserAction( @@ -145,85 +157,28 @@ export function useWorkspaceFileBrowserActionPredicate( ) } -function openDocPreviewTab( - state: AppState, - params: { filePath: string; worktreeId: string; activate: boolean }, - placement?: Pick -): void { - const docLocation = { - kind: 'workspace-doc' as const, - worktreeId: params.worktreeId, - filePath: params.filePath - } - // Why reuse and not a second tab: previewing a document already on screen is a request to look at - // it, and two tabs of one document would each hold their own grant on the same file. - const existing = findWorkspaceShowingDoc(state, docLocation) - if (existing) { - if ( - !params.activate || - !activateBrowserWorkspaceTab({ - worktreeId: params.worktreeId, - ...existing - }) - ) { - state.setActiveBrowserTab(existing.workspaceId) - state.setActiveBrowserPage(existing.workspaceId, existing.pageId) - } - return - } - state.createBrowserTab(params.worktreeId, ORCA_BROWSER_BLANK_URL, { - docLocation, - title: basename(params.filePath) || params.filePath, - ...placement, - // Why explicitly client-local: the document is read through a grant this desktop mints, so the - // page never belongs to a remote runtime even when the worktree does. - browserRuntimeEnvironmentId: null, - // Why the caller decides: opening a file is a request to look at it, while a preview opened to - // the side belongs beside the source the reader is still working in. - activate: params.activate - }) -} - export function openFileInBrowserTab(params: { filePath: string worktreeId: string targetGroupId?: string + clientLocal?: boolean }): WorkspaceFilePreviewPlan { const state = useAppStore.getState() - const plan = getWorkspaceFilePreviewPlan(state, params.worktreeId, params.filePath) + const plan = getWorkspaceFilePreviewPlan( + state, + params.worktreeId, + params.filePath, + params.clientLocal + ) if (plan.status === 'unsupported') { return plan } - if (plan.status === 'doc-preview') { - openDocPreviewTab(state, { ...params, activate: true }, { targetGroupId: params.targetGroupId }) - return plan - } - - state.createBrowserTab(params.worktreeId, plan.url, { - title: plan.title, - ...(params.targetGroupId ? { targetGroupId: params.targetGroupId } : {}), - activate: true + openFilePreviewTab(state, { ...params, activate: true }, plan, { + targetGroupId: params.targetGroupId }) return plan } -/** The tab a document is already open in, found by every PAGE's docLocation and not just the - * workspace mirror — a mixed workspace whose doc page is inactive mirrors null. */ -function findWorkspaceShowingDoc( - state: AppState, - docLocation: BrowserPageDocLocation -): { workspaceId: string; pageId: string } | null { - for (const tab of state.browserTabsByWorktree[docLocation.worktreeId] ?? []) { - const page = (state.browserPagesByWorkspace[tab.id] ?? []).find((candidate) => - browserPageDocLocationsEqual(candidate.docLocation ?? null, docLocation) - ) - if (page) { - return { workspaceId: tab.id, pageId: page.id } - } - } - return null -} - /** * The address bar's way into a workspace document: reuse before converting. A document already on * screen is a request to look at it — two tabs of one document would each hold their own grant on @@ -246,7 +201,11 @@ export function convertBrowserPageToWorkspaceDoc( // history could jump there forever. A history leg's document was this tab's own, so converting // in place is right. const isHistoryLeg = options?.leg !== undefined - const existing = isHistoryLeg ? null : findWorkspaceShowingDoc(state, docLocation) + const existing = isHistoryLeg + ? null + : findWorkspaceShowingPage(state, docLocation.worktreeId, (page) => + browserPageDocLocationsEqual(page.docLocation ?? null, docLocation) + ) if (existing) { // Why the worktree switches first: activation is deliberately scoped to the active worktree, // so without the switch a cross-worktree reuse would happen entirely out of sight. @@ -339,7 +298,12 @@ export function openFilePreviewToSide(params: { : { split: { sourceGroupId, splitDirection: 'right' as const, activate } } if (plan.status === 'doc-preview') { - openDocPreviewTab(state, { filePath: params.filePath, worktreeId, activate: false }, placement) + openFilePreviewTab( + state, + { filePath: params.filePath, worktreeId, activate: false }, + plan, + placement + ) return } diff --git a/src/renderer/src/lib/open-html-file-preview.test.ts b/src/renderer/src/lib/open-html-file-preview.test.ts deleted file mode 100644 index d9eed126ebe..00000000000 --- a/src/renderer/src/lib/open-html-file-preview.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { openHtmlFilePreview } from './open-html-file-preview' - -const mocks = vi.hoisted(() => ({ - resolve: vi.fn(), - open: vi.fn(), - select: vi.fn(), - state: { activeWorktreeId: 'current' } -})) -vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) -vi.mock('./workspace-doc-address-input', () => ({ - resolveWorkspaceDocAddressTarget: mocks.resolve -})) -vi.mock('./file-preview', () => ({ openFileInBrowserTab: mocks.open })) -vi.mock('./workspace-surface-reveal', () => ({ selectWorkspaceSurface: mocks.select })) - -beforeEach(() => { - vi.clearAllMocks() - mocks.resolve.mockReturnValue({ status: 'not-a-workspace-doc' }) - mocks.open.mockReturnValue({ status: 'browser-tab' }) -}) - -const request = { - filePath: '/repo/report one.html', - worktreeId: 'current', - targetGroupId: 'group-2' -} - -it('keeps a local HTML preview in the requested group', () => { - openHtmlFilePreview(request) - expect(mocks.resolve).toHaveBeenCalledWith(mocks.state, 'current', request.filePath) - expect(mocks.open).toHaveBeenCalledWith(request) - expect(mocks.select).not.toHaveBeenCalled() -}) - -it('opens a sibling remote document in its owning workspace', () => { - mocks.resolve.mockReturnValue({ status: 'workspace-doc', docLocation: { worktreeId: 'sibling' } }) - mocks.open.mockReturnValue({ status: 'doc-preview' }) - openHtmlFilePreview(request) - expect(mocks.select).toHaveBeenCalledWith(mocks.state, 'sibling') - expect(mocks.open).toHaveBeenCalledWith({ filePath: request.filePath, worktreeId: 'sibling' }) -}) - -it('reports a refused host without creating a preview', () => { - mocks.resolve.mockReturnValue({ status: 'unsupported', message: 'outside workspace' }) - expect(() => openHtmlFilePreview(request)).toThrow('outside workspace') - expect(mocks.open).not.toHaveBeenCalled() -}) - -it('reports a preview plan refusal', () => { - mocks.open.mockReturnValue({ status: 'unsupported', message: 'unknown host' }) - expect(() => openHtmlFilePreview(request)).toThrow('unknown host') -}) diff --git a/src/renderer/src/lib/open-html-file-preview.ts b/src/renderer/src/lib/open-html-file-preview.ts deleted file mode 100644 index 3715e919313..00000000000 --- a/src/renderer/src/lib/open-html-file-preview.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { openFileInBrowserTab } from './file-preview' -import { resolveWorkspaceDocAddressTarget } from './workspace-doc-address-input' -import { selectWorkspaceSurface } from './workspace-surface-reveal' -import { useAppStore } from '@/store' - -export function openHtmlFilePreview(params: { - filePath: string - worktreeId: string - targetGroupId: string -}): void { - const state = useAppStore.getState() - const target = resolveWorkspaceDocAddressTarget(state, params.worktreeId, params.filePath) - if (target.status === 'unsupported') { - throw new Error(target.message) - } - const worktreeId = - target.status === 'workspace-doc' ? target.docLocation.worktreeId : params.worktreeId - if (state.activeWorktreeId !== worktreeId) { - selectWorkspaceSurface(state, worktreeId) - } - const plan = openFileInBrowserTab({ - filePath: params.filePath, - worktreeId, - ...(worktreeId === params.worktreeId ? { targetGroupId: params.targetGroupId } : {}) - }) - if (plan.status === 'unsupported') { - throw new Error(plan.message) - } -} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts b/src/renderer/src/lib/workspace-file-changes-open.ts similarity index 93% rename from src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts rename to src/renderer/src/lib/workspace-file-changes-open.ts index 67ceb4bfb1d..43f909c9b20 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/open-uncommitted-entry.ts +++ b/src/renderer/src/lib/workspace-file-changes-open.ts @@ -1,4 +1,4 @@ -import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { GitStatusEntry } from '../../../shared/git-status-types' import type { AppState } from '@/store/types' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' diff --git a/src/renderer/src/lib/workspace-file-open.test.ts b/src/renderer/src/lib/workspace-file-open.test.ts new file mode 100644 index 00000000000..7d505647075 --- /dev/null +++ b/src/renderer/src/lib/workspace-file-open.test.ts @@ -0,0 +1,350 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { openWorkspaceFile, type WorkspaceFileOpenRequest } from './workspace-file-open' +import type { GitStatusEntry } from '../../../shared/git-status-types' + +const mocks = vi.hoisted(() => { + const gitStatusByWorktree: Record = {} + return { + resolve: vi.fn(), + plan: vi.fn(), + open: vi.fn(), + select: vi.fn(), + activate: vi.fn(), + environmentId: null as string | null, + state: { + activeWorktreeId: 'current', + gitStatusByWorktree, + runtimeStatusByEnvironmentId: new Map(), + openBranchDiff: vi.fn(), + openDiff: vi.fn(), + openFileForLink: vi.fn(), + openFile: vi.fn((file: { filePath: string }) => file.filePath) + } + } +}) +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('./workspace-doc-address-input', () => ({ + resolveWorkspaceDocAddressTarget: mocks.resolve +})) +vi.mock('./file-preview', () => ({ + openFileInBrowserTab: mocks.open, + getWorkspaceFilePreviewPlan: mocks.plan +})) +vi.mock('./workspace-surface-reveal', () => ({ selectWorkspaceSurface: mocks.select })) +vi.mock('./worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activate })) +vi.mock('./resolve-owner', () => ({ + runtimeEnvironmentIdUnlessTwin: () => mocks.environmentId +})) + +beforeEach(() => { + vi.clearAllMocks() + mocks.state.gitStatusByWorktree = {} + mocks.environmentId = null + mocks.state.runtimeStatusByEnvironmentId.clear() + mocks.resolve.mockReturnValue({ status: 'not-a-workspace-doc' }) + mocks.open.mockReturnValue({ status: 'browser-tab' }) + mocks.plan.mockReturnValue({ status: 'browser-tab' }) + mocks.state.openFileForLink.mockReturnValue({ + fileId: 'html-source', + viewStateId: 'html-view', + groupId: 'group-2' + }) +}) + +const request = { + kind: 'preview' as const, + filePath: '/repo/report one.html', + worktreeId: 'current', + targetGroupId: 'group-2' +} + +function sourceFile(filePath: string) { + return { + filePath, + relativePath: filePath, + worktreeId: 'current', + language: 'plaintext', + mode: 'edit' as const + } +} + +it.each(['/repo/report.html', '/repo/report.HTM', 'C:\\repo\\report.HTML'])( + 'opens the default HTML surface for %s', + (filePath) => { + expect( + openWorkspaceFile({ + kind: 'file', + file: sourceFile(filePath), + options: { targetGroupId: 'group-2' } + }) + ).toEqual({ surface: 'preview' }) + expect(mocks.open).toHaveBeenCalledWith({ + filePath, + worktreeId: 'current', + targetGroupId: 'group-2' + }) + expect(mocks.state.openFile).not.toHaveBeenCalled() + } +) + +it.each(['/repo/index.ts', '/repo/page.jsp', '/repo/page.html.liquid'])( + 'opens non-HTML files in the editor: %s', + (filePath) => { + const file = sourceFile(filePath) + openWorkspaceFile({ kind: 'file', file }) + expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined) + expect(mocks.open).not.toHaveBeenCalled() + } +) + +it('keeps an explicit source request in the editor', () => { + const file = sourceFile('/repo/report.html') + openWorkspaceFile({ kind: 'file', intent: 'source', file, options: { preview: false } }) + expect(mocks.state.openFile).toHaveBeenCalledWith(file, { preview: false }) + expect(mocks.plan).not.toHaveBeenCalled() + expect(mocks.open).not.toHaveBeenCalled() +}) + +it('keeps a host-granted HTML artifact on its authorized editor path', () => { + const file = { ...sourceFile('/outside/report.html'), terminalArtifactGrantId: 'grant-1' } + openWorkspaceFile({ kind: 'file', file }) + expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined) + expect(mocks.open).not.toHaveBeenCalled() +}) + +it('keeps an external SSH HTML file on its owning editor path', () => { + const file = { ...sourceFile('/remote/report.html'), externalSshTargetId: 'ssh-target' } + expect(openWorkspaceFile({ kind: 'file', file })).toEqual({ + surface: 'editor', + fileId: file.filePath + }) + expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined) + expect(mocks.plan).not.toHaveBeenCalled() + expect(mocks.open).not.toHaveBeenCalled() +}) + +const htmlLink = { + kind: 'link', + file: { + filePath: '/repo/report.html', + relativePath: 'report.html', + worktreeId: 'folder:docs' + }, + linkIntent: null, + position: { line: null, column: null }, + isCurrentRequest: () => true, + executionHostId: 'runtime:host-1', + clientLocal: false +} satisfies WorkspaceFileOpenRequest + +it('activates an HTML link workspace with its execution host before previewing', () => { + expect(openWorkspaceFile(htmlLink)).toEqual({ surface: 'preview' }) + expect(mocks.activate).toHaveBeenCalledWith('folder:docs', { + providesInitialSurface: true, + executionHostId: 'runtime:host-1' + }) + expect(mocks.activate.mock.invocationCallOrder[0]).toBeLessThan( + mocks.open.mock.invocationCallOrder[0] + ) + expect(mocks.state.openFileForLink).not.toHaveBeenCalled() +}) + +it.each([ + { + ...htmlLink, + linkIntent: { + workspaceId: 'folder:docs', + sourceTabId: 'source-tab', + hostId: 'runtime:host-1', + targetGroupId: 'group-2' + } + }, + { ...htmlLink, file: { ...htmlLink.file, externalSshTargetId: 'ssh-target' } } +] satisfies WorkspaceFileOpenRequest[])( + 'opens HTML links with an explicit editor group or external SSH owner in the editor: %j', + (request) => { + expect(openWorkspaceFile(request)).toEqual({ surface: 'editor', fileId: 'html-source' }) + expect(mocks.state.openFileForLink).toHaveBeenCalledWith( + expect.objectContaining(request.file), + { forceContentReload: true }, + request.linkIntent, + expect.any(Function) + ) + expect(mocks.plan).not.toHaveBeenCalled() + expect(mocks.open).not.toHaveBeenCalled() + } +) + +it('falls back to the editor when the owning host cannot preview HTML', () => { + mocks.plan.mockReturnValue({ status: 'unsupported', reason: 'no-channel' }) + const file = sourceFile('/repo/report.html') + openWorkspaceFile({ kind: 'file', file }) + expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined) + expect(mocks.open).not.toHaveBeenCalled() +}) + +it.each([null, false])( + 'keeps source available when the paired host has no known scoped preview support: %s', + (supported) => { + mocks.environmentId = 'paired-host' + mocks.plan.mockReturnValue({ status: 'doc-preview' }) + if (supported === false) { + mocks.state.runtimeStatusByEnvironmentId.set('paired-host', { status: { capabilities: [] } }) + } + const file = sourceFile('/repo/report.html') + + expect(openWorkspaceFile({ kind: 'file', file })).toEqual({ + surface: 'editor', + fileId: file.filePath + }) + expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined) + expect(mocks.open).not.toHaveBeenCalled() + expect( + openWorkspaceFile({ ...htmlLink, file: { ...htmlLink.file, worktreeId: 'current' } }) + ).toEqual({ surface: 'editor', fileId: 'html-source' }) + expect(mocks.open).not.toHaveBeenCalled() + expect( + openWorkspaceFile({ + kind: 'uncommitted', + worktreeId: 'current', + worktreePath: '/repo', + entry: { path: 'report.html', status: 'modified', area: 'unstaged' } + }) + ).toEqual({ surface: 'diff' }) + expect(mocks.state.openDiff).toHaveBeenCalled() + } +) + +it('defaults to scoped previews only when the paired host advertises support', () => { + mocks.environmentId = 'paired-host' + mocks.plan.mockReturnValue({ status: 'doc-preview' }) + mocks.state.runtimeStatusByEnvironmentId.set('paired-host', { + status: { capabilities: ['files.readDocPreview'] } + }) + + expect(openWorkspaceFile({ kind: 'file', file: sourceFile('/repo/report.html') })).toEqual({ + surface: 'preview' + }) + expect(mocks.open).toHaveBeenCalledOnce() + expect(mocks.state.openFile).not.toHaveBeenCalled() +}) + +it('retains explicit preview behavior on an older paired host', () => { + mocks.environmentId = 'paired-host' + mocks.open.mockReturnValue({ status: 'doc-preview' }) + + expect(openWorkspaceFile(request)).toEqual({ surface: 'preview' }) + expect(mocks.state.openFile).not.toHaveBeenCalled() +}) + +const branchRequest = { + kind: 'branch', + worktreeId: 'current', + worktreePath: '/repo', + entry: { path: 'report.html', status: 'modified' }, + compare: { + baseRef: 'main', + baseOid: 'base', + compareRef: 'HEAD', + headOid: 'head', + mergeBase: 'base' + } +} as const + +it.each([ + [{ path: 'report.html', status: 'deleted', area: 'staged' }], + [{ path: 'report.html', status: 'deleted', area: 'unstaged' }], + [{ path: 'renamed.html', oldPath: 'report.html', status: 'renamed', area: 'staged' }] +] satisfies GitStatusEntry[][])( + 'opens a committed diff when the working file is absent: %j', + (...changes) => { + mocks.state.gitStatusByWorktree.current = changes + + expect(openWorkspaceFile(branchRequest)).toEqual({ surface: 'diff' }) + expect(mocks.state.openBranchDiff).toHaveBeenCalledWith( + 'current', + '/repo', + branchRequest.entry, + branchRequest.compare, + 'html', + undefined + ) + expect(mocks.open).not.toHaveBeenCalled() + } +) + +it.each([ + { path: 'report.html', status: 'deleted', area: 'staged' }, + { path: 'renamed.html', oldPath: 'report.html', status: 'renamed', area: 'staged' } +] satisfies GitStatusEntry[])( + 'previews a recreated working file after a staged $status', + (stagedChange) => { + const entry: GitStatusEntry = { path: 'report.html', status: 'untracked', area: 'untracked' } + mocks.state.gitStatusByWorktree.current = [stagedChange, entry] + + expect(openWorkspaceFile(branchRequest)).toEqual({ surface: 'preview' }) + expect( + openWorkspaceFile({ + kind: 'uncommitted', + worktreeId: 'current', + worktreePath: '/repo', + entry + }) + ).toEqual({ surface: 'preview' }) + expect(mocks.open).toHaveBeenCalledWith({ + filePath: '/repo/report.html', + worktreeId: 'current', + targetGroupId: undefined + }) + expect(mocks.state.openBranchDiff).not.toHaveBeenCalled() + expect(mocks.state.openDiff).not.toHaveBeenCalled() + } +) + +it.each([ + { path: 'report.html', oldPath: 'before.html', status: 'renamed', area: 'staged' }, + { path: 'copy.html', oldPath: 'report.html', status: 'copied', area: 'staged' } +] satisfies GitStatusEntry[])( + 'previews the existing file after a staged $status to $path', + (change) => { + mocks.state.gitStatusByWorktree.current = [change] + + expect(openWorkspaceFile(branchRequest)).toEqual({ surface: 'preview' }) + expect(mocks.open).toHaveBeenCalledWith({ + filePath: '/repo/report.html', + worktreeId: 'current', + targetGroupId: undefined + }) + expect(mocks.state.openBranchDiff).not.toHaveBeenCalled() + } +) + +it('keeps a local HTML preview in the requested group', () => { + openWorkspaceFile(request) + expect(mocks.resolve).toHaveBeenCalledWith(mocks.state, 'current', request.filePath) + expect(mocks.open).toHaveBeenCalledWith({ + filePath: request.filePath, + worktreeId: request.worktreeId, + targetGroupId: request.targetGroupId + }) + expect(mocks.select).not.toHaveBeenCalled() +}) + +it('opens a sibling remote document in its owning workspace', () => { + mocks.resolve.mockReturnValue({ status: 'workspace-doc', docLocation: { worktreeId: 'sibling' } }) + mocks.open.mockReturnValue({ status: 'doc-preview' }) + openWorkspaceFile(request) + expect(mocks.select).toHaveBeenCalledWith(mocks.state, 'sibling') + expect(mocks.open).toHaveBeenCalledWith({ filePath: request.filePath, worktreeId: 'sibling' }) +}) + +it('reports a refused host without creating a preview', () => { + mocks.resolve.mockReturnValue({ status: 'unsupported', message: 'outside workspace' }) + expect(() => openWorkspaceFile(request)).toThrow('outside workspace') + expect(mocks.open).not.toHaveBeenCalled() +}) + +it('reports a preview plan refusal', () => { + mocks.open.mockReturnValue({ status: 'unsupported', message: 'unknown host' }) + expect(() => openWorkspaceFile(request)).toThrow('unknown host') +}) diff --git a/src/renderer/src/lib/workspace-file-open.ts b/src/renderer/src/lib/workspace-file-open.ts new file mode 100644 index 00000000000..d33e9ae4a89 --- /dev/null +++ b/src/renderer/src/lib/workspace-file-open.ts @@ -0,0 +1,233 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { + OpenFile, + BranchCompareLike, + EditorOpenTargetOptions +} from '@/store/slices/editor/types/open-file' +import type { OpenFileOptions } from '@/store/slices/editor/types/editor-files-slice' +import type { GitStatusEntry } from '../../../shared/git-status-types' +import type { GitBranchChangeEntry } from '../../../shared/git-diff-compare-types' +import { isHtmlFilePath } from '../../../shared/html-file-url' +import { detectLanguage } from './language-detect' +import { joinPath } from './path' +import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from './file-preview' +import { openUncommittedEntry } from './workspace-file-changes-open' +import { resolveWorkspaceDocAddressTarget } from './workspace-doc-address-input' +import { selectWorkspaceSurface } from './workspace-surface-reveal' +import { openLinkedFileInEditor } from './editor-file-link-open' +import type { EditorOpenLinkIntent } from '@/store/slices/editor/types/editor-open-link-intent' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { activateAndRevealWorkspace } from './worktree-activation' +import { runtimeEnvironmentIdUnlessTwin } from './resolve-owner' +import { FILE_DOC_PREVIEW_RUNTIME_CAPABILITY } from '../../../shared/files-runtime-capabilities' + +type ChangeOpenRequest = { + worktreeId: string + worktreePath: string + intent?: 'default' | 'diff' + options?: EditorOpenTargetOptions +} & ( + | { kind: 'uncommitted'; entry: GitStatusEntry } + | { kind: 'branch'; entry: GitBranchChangeEntry; compare: BranchCompareLike } +) + +export type WorkspaceFileOpenRequest = + | { + kind: 'file' + file: Omit + intent?: 'default' | 'source' + options?: OpenFileOptions + } + | { kind: 'preview'; filePath: string; worktreeId: string; targetGroupId?: string } + | { + kind: 'link' + file: Omit + intent?: 'default' | 'source' + linkIntent: EditorOpenLinkIntent | null + position: { line: number | null; column: number | null } + isCurrentRequest: () => boolean + executionHostId: ExecutionHostId + clientLocal: boolean + } + | ChangeOpenRequest + +export type WorkspaceFileOpenResult = + | { surface: 'preview' } + | { surface: 'editor'; fileId: string } + | { surface: 'diff' } + | { surface: 'refused' } + +export type WorkspaceFileOpener = typeof openWorkspaceFile + +function hasWorkingTreeFile( + entry: Pick +): boolean { + return entry.status !== 'deleted' && !entry.conflictStatus && !entry.submodule +} + +function hasCurrentWorkingTreeFile(path: string, changes: GitStatusEntry[]): boolean { + const workingTreeChange = changes.find( + (change) => change.path === path && change.area !== 'staged' + ) + if (workingTreeChange) { + return hasWorkingTreeFile(workingTreeChange) + } + return !changes.some( + (change) => + (change.path === path && !hasWorkingTreeFile(change)) || + (change.status === 'renamed' && change.oldPath === path) + ) +} + +function openRequestedPreview( + request: Extract +): WorkspaceFileOpenResult { + const state = useAppStore.getState() + const target = resolveWorkspaceDocAddressTarget(state, request.worktreeId, request.filePath) + if (target.status === 'unsupported') { + throw new Error(target.message) + } + const worktreeId = + target.status === 'workspace-doc' ? target.docLocation.worktreeId : request.worktreeId + if (state.activeWorktreeId !== worktreeId) { + selectWorkspaceSurface(state, worktreeId) + } + const plan = openFileInBrowserTab({ + filePath: request.filePath, + worktreeId, + ...(worktreeId === request.worktreeId ? { targetGroupId: request.targetGroupId } : {}) + }) + if (plan.status === 'unsupported') { + throw new Error(plan.message) + } + return { surface: 'preview' } +} + +function defaultPreviewTarget( + request: Exclude, + state: Pick +): Parameters[0] | null { + if (request.intent === 'source' || request.intent === 'diff') { + return null + } + const filePath = + 'file' in request ? request.file.filePath : joinPath(request.worktreePath, request.entry.path) + if (!isHtmlFilePath(filePath)) { + return null + } + switch (request.kind) { + case 'file': + return request.file.mode === 'edit' && + !request.file.terminalArtifactGrantId && + !request.file.externalSshTargetId + ? { + filePath, + worktreeId: request.file.worktreeId, + targetGroupId: request.options?.targetGroupId + } + : null + case 'link': + return request.position.line === null && + !request.file.terminalArtifactGrantId && + !request.file.externalSshTargetId && + !request.linkIntent?.targetGroupId + ? { + filePath, + worktreeId: request.file.worktreeId, + ...(request.clientLocal ? { clientLocal: true } : {}) + } + : null + case 'uncommitted': + case 'branch': + if ( + !hasWorkingTreeFile(request.entry) || + !hasCurrentWorkingTreeFile( + request.entry.path, + state.gitStatusByWorktree[request.worktreeId] ?? [] + ) + ) { + return null + } + return { + filePath, + worktreeId: request.worktreeId, + targetGroupId: request.options?.targetGroupId + } + } +} + +export function openWorkspaceFile(request: WorkspaceFileOpenRequest): WorkspaceFileOpenResult { + if (request.kind === 'preview') { + return openRequestedPreview(request) + } + const state = useAppStore.getState() + const target = defaultPreviewTarget(request, state) + if (target) { + const plan = getWorkspaceFilePreviewPlan( + state, + target.worktreeId, + target.filePath, + target.clientLocal + ) + const previewEnvironmentId = + plan.status === 'doc-preview' + ? runtimeEnvironmentIdUnlessTwin(state, target.worktreeId) + : null + if ( + plan.status !== 'unsupported' && + (!previewEnvironmentId || + state.runtimeStatusByEnvironmentId + ?.get(previewEnvironmentId) + ?.status?.capabilities?.includes(FILE_DOC_PREVIEW_RUNTIME_CAPABILITY) === true) + ) { + if (request.kind === 'link') { + activateAndRevealWorkspace(target.worktreeId, { + providesInitialSurface: true, + executionHostId: request.executionHostId + }) + } + if (openFileInBrowserTab(target).status !== 'unsupported') { + return { surface: 'preview' } + } + } + } + if (request.kind === 'link') { + const worktreeId = request.file.worktreeId + if (worktreeId) { + activateAndRevealWorkspace(worktreeId, { + providesInitialSurface: true, + executionHostId: request.executionHostId + }) + } + const opened = openLinkedFileInEditor( + request.file, + request.linkIntent, + request.position, + request.isCurrentRequest + ) + return opened ? { surface: 'editor', fileId: opened.fileId } : { surface: 'refused' } + } + if (request.kind === 'file') { + return { surface: 'editor', fileId: state.openFile(request.file, request.options) } + } + if (request.kind === 'uncommitted') { + openUncommittedEntry( + state, + request.worktreeId, + request.worktreePath, + request.entry, + request.options + ) + } else { + state.openBranchDiff( + request.worktreeId, + request.worktreePath, + request.entry, + request.compare, + detectLanguage(request.entry.path), + request.options + ) + } + return { surface: 'diff' } +} diff --git a/src/renderer/src/lib/workspace-file-preview-page.ts b/src/renderer/src/lib/workspace-file-preview-page.ts new file mode 100644 index 00000000000..5b0cf5f6802 --- /dev/null +++ b/src/renderer/src/lib/workspace-file-preview-page.ts @@ -0,0 +1,28 @@ +import type { AppState } from '@/store/types' +import type { BrowserPage } from '../../../shared/browser-workspace-types' + +export function findWorkspaceShowingPage( + state: AppState, + worktreeId: string, + matches: (page: BrowserPage) => boolean, + targetGroupId?: string +): { workspaceId: string; pageId: string } | null { + for (const tab of state.browserTabsByWorktree[worktreeId] ?? []) { + if ( + targetGroupId && + !state.unifiedTabsByWorktree[worktreeId]?.some( + (candidate) => + candidate.contentType === 'browser' && + candidate.entityId === tab.id && + candidate.groupId === targetGroupId + ) + ) { + continue + } + const page = (state.browserPagesByWorkspace[tab.id] ?? []).find(matches) + if (page) { + return { workspaceId: tab.id, pageId: page.id } + } + } + return null +} diff --git a/src/shared/files-runtime-capabilities.ts b/src/shared/files-runtime-capabilities.ts index cae75d98bc8..9484b3d42b4 100644 --- a/src/shared/files-runtime-capabilities.ts +++ b/src/shared/files-runtime-capabilities.ts @@ -5,9 +5,12 @@ export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-owners export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = 'Remote file changes require a newer Orca server. Update the HUB and try again.' +export const FILE_DOC_PREVIEW_RUNTIME_CAPABILITY = 'files.readDocPreview' as const + export const FILES_RUNTIME_CAPABILITIES = [ 'files.pathsExist', 'files.resolve-terminal-path.cross-workspace.v1', + FILE_DOC_PREVIEW_RUNTIME_CAPABILITY, FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/html-file-url.ts b/src/shared/html-file-url.ts index 21a5a597cae..9eb3fcbb39b 100644 --- a/src/shared/html-file-url.ts +++ b/src/shared/html-file-url.ts @@ -1,5 +1,9 @@ import { fileUriToFilesystemPath } from './file-uri-path' +export function isHtmlFilePath(filePath: string): boolean { + return /\.html?$/i.test(filePath) +} + /** File authorities name this machine, never an Orca SSH target. */ export function htmlFilePathFromUrl(input: string): string | null { if (!/^file:\/\//i.test(input) || /[?#\p{Cc}]|%2f|%5c/iu.test(input)) { @@ -11,7 +15,7 @@ export function htmlFilePathFromUrl(input: string): string | null { return null } const filePath = fileUriToFilesystemPath(url) - return filePath && /\.html?$/i.test(filePath) && !/\p{Cc}/u.test(filePath) ? filePath : null + return filePath && isHtmlFilePath(filePath) && !/\p{Cc}/u.test(filePath) ? filePath : null } catch { return null } diff --git a/tests/e2e/html-file-url-opening.spec.ts b/tests/e2e/html-file-url-opening.spec.ts index 321864f8af7..77885793503 100644 --- a/tests/e2e/html-file-url-opening.spec.ts +++ b/tests/e2e/html-file-url-opening.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import { expect, test } from './helpers/orca-app' @@ -9,24 +9,35 @@ import { import { readDocPreviewRenderedText } from './helpers/paired-html-preview-inventory' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -test('opens a local HTML file URL with encoded characters from the new-tab menu', async ({ +test('opens HTML from Explorer as a preview and lets the user edit its source', async ({ electronApp, - orcaPage, - testRepoPath + orcaPage }, testInfo) => { - const filePath = path.join(testRepoPath, 'report 中%20.html') - writeFileSync(filePath, 'Local file URL

Local HTML rendered

') - const url = pathToFileURL(filePath).href + const name = 'default-preview.html' await waitForSessionReady(orcaPage) const worktreeId = await waitForActiveWorktree(orcaPage) - await orcaPage.getByRole('button', { name: 'New tab', exact: true }).first().click() - const input = orcaPage.getByRole('combobox', { - name: 'Search open tabs, history, files, URLs, agents…', - exact: true + const root = await orcaPage.evaluate( + (id) => window.__store?.getState().getKnownWorktreeById(id)?.path, + worktreeId + ) + if (!root) { + throw new Error('Active workspace missing') + } + const filePath = path.join(root, name) + const url = pathToFileURL(filePath).href + writeFileSync(filePath, 'HTML default

Rendered by default

') + await orcaPage.evaluate(() => { + window.localStorage.setItem('orca.browser.markup-draw-hint-seen', 'true') + const state = window.__store?.getState() + state?.setRightSidebarOpen(true) + state?.setRightSidebarTab('explorer') }) - await input.fill(url) - await expect(orcaPage.getByRole('option', { name: /Open file/ })).toBeVisible() - await input.press('Enter') + await orcaPage.getByRole('button', { name: 'Refresh Explorer', exact: true }).click() + const row = orcaPage.locator('[data-file-explorer-row]').filter({ + has: orcaPage.locator('[data-file-explorer-row-name]').getByText(name, { exact: true }) + }) + await expect(row).toBeVisible() + await row.click() await expect .poll(() => electronApp.evaluate(async ({ webContents }, fileUrl) => { @@ -36,19 +47,106 @@ test('opens a local HTML file URL with encoded characters from the new-tab menu' return guest?.executeJavaScript('document.querySelector("h1")?.textContent ?? null') }, url) ) - .toBe('Local HTML rendered') - expect( - await orcaPage.evaluate( - ({ worktreeId, url }) => - window.__store - ?.getState() - .browserTabsByWorktree[worktreeId]?.some((tab) => tab.url === url && !tab.docLocation), - { worktreeId, url } + .toBe('Rendered by default') + await electronApp.evaluate(async ({ webContents }, fileUrl) => { + const guest = webContents.getAllWebContents().find((contents) => contents.getURL() === fileUrl) + await guest?.executeJavaScript( + 'const input = document.createElement("input"); input.id = "preview-state"; input.value = "Keep my input"; document.body.append(input)' ) - ).toBe(true) - await orcaPage.screenshot({ path: testInfo.outputPath('local-html-file-url.png') }) + }, url) + await row.click() + await expect + .poll(() => + electronApp.evaluate(async ({ webContents }, fileUrl) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getURL() === fileUrl) + return guest?.executeJavaScript('document.querySelector("#preview-state")?.value ?? null') + }, url) + ) + .toBe('Keep my input') + await orcaPage.screenshot({ path: testInfo.outputPath('html-default-preview.png') }) + await orcaPage.getByRole('button', { name: 'Open source file', exact: true }).click() + await expect(orcaPage.locator('.editor-header-path')).toContainText(name) + await expect(orcaPage.locator('.monaco-editor').first()).toBeVisible() + await expect(orcaPage.locator('.monaco-editor').first()).toContainText('Rendered by default') + await orcaPage.locator('.monaco-editor').first().click() + await orcaPage.keyboard.press('ControlOrMeta+End') + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.type('

Source updated

') + await orcaPage.keyboard.press('ControlOrMeta+S') + await expect.poll(() => readFileSync(filePath, 'utf8')).toContain('

Source updated

') + await row.click() + await expect( + orcaPage.getByRole('button', { name: 'Open source file', exact: true }) + ).toBeVisible() + await expect + .poll(() => + electronApp.evaluate(async ({ webContents }, fileUrl) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getURL() === fileUrl) + return guest?.executeJavaScript('document.body.textContent ?? ""') + }, url) + ) + .toContain('Source updated') + const previewTab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'HTML default' }) + await expect(previewTab).toHaveCount(1) + await expect(previewTab).toBeVisible() }) +for (const inputKind of ['file URL', 'absolute path', 'linked path'] as const) { + test(`opens a local HTML ${inputKind} with encoded characters from the new-tab menu`, async ({ + electronApp, + orcaPage, + testRepoPath + }, testInfo) => { + let directory = testRepoPath + if (inputKind === 'linked path') { + const target = testInfo.outputPath('external-source') + mkdirSync(target, { recursive: true }) + directory = path.join(testRepoPath, 'linked-source') + symlinkSync(target, directory, process.platform === 'win32' ? 'junction' : 'dir') + } + const filePath = path.join(directory, 'report 中%20.html') + writeFileSync(filePath, 'Local file URL

Local HTML rendered

') + const url = pathToFileURL(filePath).href + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await orcaPage.getByRole('button', { name: 'New tab', exact: true }).first().click() + const input = orcaPage.getByRole('combobox', { + name: 'Search open tabs, history, files, URLs, agents…', + exact: true + }) + await input.fill(inputKind === 'file URL' ? url : filePath) + await expect(orcaPage.getByRole('option', { name: /Open file/ })).toBeVisible() + await input.press('Enter') + await expect + .poll(() => + electronApp.evaluate(async ({ webContents }, fileUrl) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getURL() === fileUrl) + return guest?.executeJavaScript('document.querySelector("h1")?.textContent ?? null') + }, url) + ) + .toBe('Local HTML rendered') + await expect( + orcaPage.getByRole('button', { name: 'Open source file', exact: true }) + ).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('local-html-file-url.png') }) + await orcaPage.getByRole('button', { name: 'Open source file', exact: true }).click() + const editor = orcaPage.locator('.monaco-editor').first() + await expect(editor).toContainText('Local HTML rendered') + await editor.click() + await orcaPage.keyboard.press('ControlOrMeta+End') + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.type('

Source saved

') + await orcaPage.keyboard.press('ControlOrMeta+S') + await expect.poll(() => readFileSync(filePath, 'utf8')).toContain('

Source saved

') + }) +} + test('opens a pasted HTML file URL from a paired host and reloads its assets', async ({ orcaPage, testRepoPath diff --git a/tests/e2e/html-source-control-preview.spec.ts b/tests/e2e/html-source-control-preview.spec.ts new file mode 100644 index 00000000000..fbf4b6cd373 --- /dev/null +++ b/tests/e2e/html-source-control-preview.spec.ts @@ -0,0 +1,109 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { runProcessSync } from '@orca/process-host' +import { expect, test } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupGoldenWorktree, + createGoldenWorktree, + openGoldenSourceControl +} from './helpers/golden-source-control' + +test('previews untracked HTML clicked in Source Control', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + const root = await orcaPage.evaluate( + (id) => window.__store?.getState().getKnownWorktreeById(id)?.path, + worktreeId + ) + if (!root) { + throw new Error('Active workspace missing') + } + const name = 'source-control-preview.html' + const filePath = path.join(root, name) + const url = pathToFileURL(filePath).href + writeFileSync(filePath, 'HTML preview

Rendered from Source Control

') + await orcaPage.evaluate(() => { + window.localStorage.setItem('orca.browser.markup-draw-hint-seen', 'true') + const state = window.__store?.getState() + state?.setRightSidebarOpen(true) + state?.setRightSidebarTab('source-control') + }) + const row = orcaPage.locator(`[data-source-control-path="${name}"]`) + await expect(row).toBeVisible({ timeout: 30_000 }) + await row.click() + + await expect( + orcaPage.getByRole('button', { name: 'Open source file', exact: true }) + ).toBeVisible() + await expect(orcaPage.locator('.monaco-diff-editor')).toHaveCount(0) + await expect + .poll(() => + electronApp.evaluate(async ({ webContents }, fileUrl) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getURL() === fileUrl) + return guest?.executeJavaScript('document.querySelector("h1")?.textContent ?? null') + }, url) + ) + .toBe('Rendered from Source Control') + await orcaPage.screenshot({ path: testInfo.outputPath('html-source-control-preview.png') }) +}) + +test('previews HTML clicked under Committed on branch', async ({ + electronApp, + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}, testInfo) => { + const fixture = createGoldenWorktree(testRepoPath, 'html-branch-preview') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + const name = 'committed-preview.html' + const filePath = path.join(fixture.worktreePath, name) + const url = pathToFileURL(filePath).href + writeFileSync(filePath, 'Committed HTML

Rendered from branch changes

') + for (const args of [ + ['add', '--', name], + ['commit', '-m', 'Add HTML preview fixture'] + ]) { + const result = runProcessSync({ + program: 'git', + args, + cwd: fixture.worktreePath, + stdio: 'pipe' + }) + if (result.code !== 0) { + throw new Error(result.stderr || 'Failed to prepare committed HTML fixture') + } + } + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + await orcaPage.evaluate(() => { + window.localStorage.setItem('orca.browser.markup-draw-hint-seen', 'true') + }) + const row = orcaPage.locator( + `[data-source-control-area="branch"][data-source-control-path="${name}"]` + ) + await expect(row).toBeVisible({ timeout: 30_000 }) + await row.click() + + await expect( + orcaPage.getByRole('button', { name: 'Open source file', exact: true }) + ).toBeVisible() + await expect(orcaPage.locator('.monaco-diff-editor')).toHaveCount(0) + await expect + .poll(() => + electronApp.evaluate(async ({ webContents }, fileUrl) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getURL() === fileUrl) + return guest?.executeJavaScript('document.querySelector("h1")?.textContent ?? null') + }, url) + ) + .toBe('Rendered from branch changes') + await orcaPage.screenshot({ path: testInfo.outputPath('html-committed-branch-preview.png') }) +})