diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index dc3cfcef0137..d5a1bf7fca7b 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -12,6 +12,7 @@ import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { isBrowserPreviewFile } from "~/browser/openFileInPreview"; import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; @@ -37,6 +38,8 @@ interface FileBrowserPanelProps { onOpenFile: (relativePath: string) => void; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; + /** Present when the runtime can open a workspace file in the integrated browser. */ + onOpenInBrowser?: ((relativePath: string) => void) | undefined; } function treePath(entry: ProjectEntry): string { @@ -101,6 +104,7 @@ export default function FileBrowserPanel({ onOpenFile, onRefreshSelectedFile, workspaceMutationId, + onOpenInBrowser, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); @@ -144,6 +148,10 @@ export default function FileBrowserPanel({ } const relativePath = item.path.replace(/\/$/, ""); const mention = serializeComposerFileLink(relativePath); + const canOpenInBrowser = + onOpenInBrowser !== undefined && + entryKindsRef.current.get(relativePath) === "file" && + isBrowserPreviewFile(relativePath); const pointer = contextMenuPointerRef.current; const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000; const anchorRect = context.anchorElement.getBoundingClientRect(); @@ -153,11 +161,18 @@ export default function FileBrowserPanel({ try { const clicked = await api.contextMenu.show( [ + ...(canOpenInBrowser + ? [{ id: "open-in-browser" as const, label: "Open in browser" }] + : []), { id: "copy-mention", label: "Copy mention" }, { id: "add-to-chat", label: "Add to chat" }, ], position, ); + if (clicked === "open-in-browser") { + onOpenInBrowser?.(relativePath); + return; + } if (clicked === "copy-mention") { try { await writeTextToClipboard(mention); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 0ad18434d7e7..a7607b4d092a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -36,7 +36,7 @@ import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffR import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; -import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; +import { isAbsolutePath, resolveWorkspaceFilePath } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; @@ -1066,7 +1066,7 @@ export default function FilePreviewPanel({ isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); const absolutePath = - relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; + relativePath && attachment === undefined ? resolveWorkspaceFilePath(relativePath, cwd) : null; const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); useWorkspaceMutationRefresh({ enabled: @@ -1099,30 +1099,35 @@ export default function FilePreviewPanel({ }); }; - const handleOpenInBrowser = useCallback(() => { - if (!absolutePath || !environmentHttpBaseUrl) return; - void (async () => { - const result = await openFileInPreview({ - threadRef, - filePath: absolutePath, - workspaceRoot: cwd, - httpBaseUrl: environmentHttpBaseUrl, - createAssetUrl, - openPreview, - }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - })(); - }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); + const handleOpenInBrowser = useCallback( + (filePath: string) => { + if (!environmentHttpBaseUrl) return; + void (async () => { + const result = await openFileInPreview({ + threadRef, + // Tree and preview-header paths are workspace-relative; resolve them + // against the workspace root without terminal-link `~/` expansion. + filePath: resolveWorkspaceFilePath(filePath, cwd), + workspaceRoot: cwd, + httpBaseUrl: environmentHttpBaseUrl, + createAssetUrl, + openPreview, + }); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + })(); + }, + [createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef], + ); return (
@@ -1205,7 +1210,7 @@ export default function FilePreviewPanel({ handleOpenInBrowser(relativePath)} aria-label="Open file in preview browser" variant="ghost" size="sm" @@ -1368,6 +1373,11 @@ export default function FilePreviewPanel({ {...(relativePath && !isMedia && !isPdf ? { onRefreshSelectedFile: file.refresh } : {})} + onOpenInBrowser={ + isPreviewSupportedInRuntime() && environmentHttpBaseUrl + ? (browserPath) => handleOpenInBrowser(browserPath) + : undefined + } /> ) : null} diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 204d0a742a05..ec2733a463a7 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -264,3 +264,14 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { return formatFilePathPosition({ ...position, path: resolvedPath }); } + +/** + * Resolves a workspace-relative file path against the workspace root. Unlike + * terminal links, a leading `~/` is a literal folder name inside the + * workspace, not the user's home directory. + */ +export function resolveWorkspaceFilePath(rawPath: string, cwd: string): string { + if (isAbsolutePath(rawPath)) return rawPath; + const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; + return joinPath(cwd, rawPath, separator); +}