From 1ef6a1ad0deafb0db50c776e51111a8a44354e3d Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 17 Sep 2026 10:34:44 -0700 Subject: [PATCH 1/7] feat(web): add link actions to the terminal context menu --- apps/web/src/components/ChatView.tsx | 39 +++ .../components/ThreadTerminalDrawer.test.ts | 104 +++++++ .../src/components/ThreadTerminalDrawer.tsx | 251 ++++++++++++--- .../preview/fileExplorerLabel.test.ts | 24 ++ .../components/preview/fileExplorerLabel.ts | 27 ++ .../preview/openTerminalLinkInPreview.ts | 55 ++-- apps/web/src/fileManagerReveal.test.ts | 245 +++++++++++++++ apps/web/src/fileManagerReveal.ts | 287 ++++++++++++++++++ apps/web/src/terminal-links.test.ts | 10 + apps/web/src/terminal-links.ts | 15 + apps/web/src/terminal/ghostty/surface.test.ts | 32 ++ apps/web/src/terminal/ghostty/surface.ts | 66 +++- 12 files changed, 1084 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/fileManagerReveal.test.ts create mode 100644 apps/web/src/fileManagerReveal.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c9540e4a0a84..77cd145609b6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -897,6 +897,7 @@ interface PersistentThreadTerminalDrawerProps { closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalLink?: (text: string) => void; } const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ @@ -911,6 +912,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra closeShortcutLabel, keybindings, onAddTerminalContext, + onAddTerminalLink, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); @@ -1211,6 +1213,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra [onAddTerminalContext, visible], ); + const handleAddTerminalLink = useCallback( + (text: string) => { + if (!visible || !onAddTerminalLink) { + return; + } + onAddTerminalLink(text); + }, + [onAddTerminalLink, visible], + ); + if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { return null; } @@ -1252,6 +1264,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra onCloseTerminal={closeTerminal} onHeightChange={setTerminalHeight} onAddTerminalContext={handleAddTerminalContext} + onAddTerminalLink={onAddTerminalLink ? handleAddTerminalLink : undefined} terminalLabelsById={terminalLabelsById} terminalLaunchLocationsById={terminalLaunchLocationsById} /> @@ -1268,6 +1281,7 @@ interface PersistentThreadTerminalPanelProps { focusRequestId: number; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalLink?: (text: string) => void; onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; @@ -1287,6 +1301,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane focusRequestId, keybindings, onAddTerminalContext, + onAddTerminalLink, onSplitTerminal, onSplitTerminalVertical, onNewTerminal, @@ -1337,6 +1352,15 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane : {}, [project, worktreePath], ); + const handleAddTerminalLink = useCallback( + (text: string) => { + if (!visible || !onAddTerminalLink) { + return; + } + onAddTerminalLink(text); + }, + [onAddTerminalLink, visible], + ); const terminalLabelsById = useMemo(() => { const labels = new Map(); for (const terminalId of surface.terminalIds) { @@ -1425,6 +1449,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane onCloseTerminal={onCloseTerminal} onHeightChange={() => undefined} onAddTerminalContext={onAddTerminalContext} + onAddTerminalLink={onAddTerminalLink ? handleAddTerminalLink : undefined} terminalLabelsById={terminalLabelsById} terminalLaunchLocationsById={terminalLaunchLocationsById} keybindings={keybindings} @@ -4028,6 +4053,18 @@ export default function ChatView(props: ChatViewProps) { }, [composerRef], ); + const addTerminalLinkToDraft = useCallback( + (text: string) => { + if (!composerRef.current?.insertTextAtEnd(`${text} `, { ensureLeadingBoundary: true })) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + } + }, + [composerRef], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -9621,6 +9658,7 @@ export default function ChatView(props: ChatViewProps) { focusRequestId={terminalFocusRequestId} keybindings={keybindings} onAddTerminalContext={addTerminalContextToDraft} + onAddTerminalLink={addTerminalLinkToDraft} onSplitTerminal={splitPanelTerminal} onSplitTerminalVertical={splitPanelTerminalVertical} onNewTerminal={addTerminalSurface} @@ -10320,6 +10358,7 @@ export default function ChatView(props: ChatViewProps) { closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} keybindings={keybindings} onAddTerminalContext={addTerminalContextToDraft} + onAddTerminalLink={addTerminalLinkToDraft} /> ))} diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 1624a739bb1a..04ca2a2b936a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -7,6 +7,8 @@ import { terminalSelectionLineRange, terminalSelectionMenuItems, terminalThemeFromApp, + terminalLinkChatText, + terminalLinkCopyText, } from "./ThreadTerminalDrawer"; describe("terminal selection menus", () => { @@ -27,6 +29,108 @@ describe("terminal selection menus", () => { }); }); +describe("terminal link menus", () => { + it("adds path actions before selection actions", () => { + const items = terminalContextMenuItems({ + hasSelection: true, + link: { text: "src/main.ts:12", editorLabel: "Open in VS Code" }, + }); + + expect(items.map(({ id }) => id)).toEqual([ + "open-terminal-path", + "add-terminal-path-to-chat", + "copy-terminal-path", + "add-to-chat", + "copy", + "paste", + ]); + expect(items[0]).toMatchObject({ id: "open-terminal-path", label: "Open in VS Code" }); + expect(items.find(({ id }) => id === "add-to-chat")).toMatchObject({ + separatorBefore: true, + }); + expect(items.slice(0, 3).every((item) => !item.separatorBefore)).toBe(true); + }); + + it.each([ + ["with reveal", "Reveal in Finder", true], + ["without reveal", undefined, false], + ])("handles path reveal action %s", (_name, revealLabel, hasReveal) => { + const items = terminalContextMenuItems({ + hasSelection: true, + link: { text: "src/main.ts:12", ...(revealLabel ? { revealLabel } : {}) }, + }); + const ids = items.map(({ id }) => id); + expect(ids.includes("reveal-terminal-path")).toBe(hasReveal); + if (hasReveal) { + expect(ids.indexOf("reveal-terminal-path")).toBe(ids.indexOf("open-terminal-path") + 1); + } + }); + + it("does not add reveal action to URL links", () => { + expect( + terminalContextMenuItems({ + hasSelection: true, + link: { text: "https://example.com", revealLabel: "Reveal in Finder" }, + }).map(({ id }) => id), + ).not.toContain("reveal-terminal-path"); + }); + + it("keeps the selection menu unchanged when there is no link", () => { + const items = terminalContextMenuItems({ hasSelection: true }); + + expect(items.map(({ id }) => id)).toEqual(["add-to-chat", "copy", "paste"]); + expect(items.every((item) => !item.separatorBefore)).toBe(true); + }); + + it("omits URL chat actions when adding to chat is unavailable", () => { + const items = terminalContextMenuItems({ + hasSelection: true, + canAddToChat: false, + link: { text: "https://example.com", canOpenPreview: true }, + }); + + expect(items.map(({ id }) => id)).not.toContain("add-terminal-link-to-chat"); + expect(items.map(({ id }) => id)).not.toContain("add-to-chat"); + }); + + it("gates the link add-to-chat item separately from the selection one", () => { + const ids = terminalContextMenuItems({ + hasSelection: true, + link: { text: "src/main.ts", canAddToChat: false }, + }).map(({ id }) => id); + + expect(ids).not.toContain("add-terminal-path-to-chat"); + expect(ids).toContain("add-to-chat"); + }); + + it("places URL actions before selection actions", () => { + expect( + terminalContextMenuItems({ + hasSelection: true, + link: { text: "https://example.com", canOpenPreview: true }, + }).map(({ id }) => id), + ).toEqual([ + "open-terminal-link-preview", + "open-terminal-link-browser", + "add-terminal-link-to-chat", + "copy-terminal-link", + "add-to-chat", + "copy", + "paste", + ]); + }); + + it("serializes paths for chat and strips positions for copying", () => { + expect(terminalLinkChatText("src/main.ts:12:3", "/repo")).toContain("src/main.ts"); + expect(terminalLinkChatText("/", "/repo")).toBe("/"); + expect(terminalLinkChatText("src\\", "/repo")).toBe("[src](/repo/src)"); + expect(terminalLinkChatText("C:\\", "C:\\repo")).not.toContain("repo"); + expect(terminalLinkChatText("https://example.com/a", "/repo")).toBe("https://example.com/a"); + expect(terminalLinkCopyText("src/main.ts:12:3")).toBe("src/main.ts"); + expect(terminalLinkCopyText("https://example.com:8080/a")).toBe("https://example.com:8080/a"); + }); +}); + describe("terminalThemeFromApp", () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91c7cc855596..d0415cd4d9c4 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -26,6 +26,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { splitFilePathPosition } from "@t3tools/client-runtime/markdown-links"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, @@ -56,7 +58,8 @@ import { type GhosttyTerminalSurfaceOptions, } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; -import { useOpenInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor, usePreferredEditor } from "../editorPreferences"; +import { openInEditorMenuLabel } from "../editorLabels"; import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, @@ -77,11 +80,16 @@ import { readLocalApi } from "~/localApi"; import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { runFileManagerPath, useFileManagerActionForEnvironment } from "../fileManagerReveal"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { terminalEnvironment } from "../state/terminal"; -import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; +import { + canOpenTerminalLinkInPreview, + openTerminalLinkInIntegratedBrowser, + openTerminalLinkInPreview, +} from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { @@ -247,7 +255,30 @@ export function terminalSelectionLineRange(position: { }; } -export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; +export type TerminalContextMenuAction = + | "add-to-chat" + | "copy" + | "paste" + | "open-terminal-link-preview" + | "open-terminal-link-browser" + | "add-terminal-link-to-chat" + | "copy-terminal-link" + | "open-terminal-path" + | "reveal-terminal-path" + | "add-terminal-path-to-chat" + | "copy-terminal-path"; + +export function terminalLinkChatText(text: string, cwd: string): string { + if (isTerminalUrl(text)) return text; + // Trim trailing separators, but keep the one that makes `C:\` a volume root. + const path = splitFilePathPosition(text).path.replace(/(?[] { const { hasSelection, canAddToChat = true } = options; + const canAddLinkToChat = options.link?.canAddToChat ?? canAddToChat; + const linkItems: ContextMenuItem[] = options.link + ? isTerminalUrl(options.link.text) + ? [ + ...(options.link.canOpenPreview + ? ([ + { id: "open-terminal-link-preview" as const, label: "Open in integrated browser" }, + ] satisfies ContextMenuItem<"open-terminal-link-preview">[]) + : []), + { id: "open-terminal-link-browser" as const, label: "Open in system browser" }, + ...(canAddLinkToChat + ? ([ + { id: "add-terminal-link-to-chat" as const, label: "Add link to chat" }, + ] satisfies ContextMenuItem<"add-terminal-link-to-chat">[]) + : []), + { id: "copy-terminal-link", label: "Copy link", icon: "copy" }, + ] + : [ + { + id: "open-terminal-path" as const, + label: options.link.editorLabel ?? "Open in editor", + }, + ...(options.link.revealLabel + ? [{ id: "reveal-terminal-path" as const, label: options.link.revealLabel }] + : []), + ...(canAddLinkToChat + ? ([ + { id: "add-terminal-path-to-chat" as const, label: "Add path to chat" }, + ] satisfies ContextMenuItem<"add-terminal-path-to-chat">[]) + : []), + { id: "copy-terminal-path", label: "Copy path", icon: "copy" }, + ] + : []; + const selectionItems = terminalSelectionMenuItems({ canAddToChat }).map((item, index) => ({ + ...item, + disabled: !hasSelection, + ...(linkItems.length > 0 && index === 0 ? { separatorBefore: true } : {}), + })); return [ - ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({ - ...item, - disabled: !hasSelection, - })), - { id: "paste", label: "Paste" }, - ]; + ...linkItems, + ...selectionItems, + { id: "paste" as const, label: "Paste" }, + ] satisfies ContextMenuItem[]; } /** @@ -318,6 +393,7 @@ interface TerminalViewportProps { providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; onAddTerminalContext?: (selection: TerminalContextSelection) => void; + onAddTerminalLink?: (text: string) => void; focusRequestId: number; autoFocus: boolean; visible: boolean; @@ -344,6 +420,7 @@ export function TerminalViewport({ providerInstanceId, onSessionExited, onAddTerminalContext, + onAddTerminalLink, focusRequestId, autoFocus, visible, @@ -361,6 +438,8 @@ export function TerminalViewport({ serverConfig?.availableEditors ?? [], ); const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); + const fileManagerAction = useFileManagerActionForEnvironment(environmentId); + const readFileManagerReveal = useEffectEvent(() => fileManagerAction?.reveal ?? null); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -385,6 +464,12 @@ export function TerminalViewport({ onAddTerminalContext?.(selection); }); const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); + const canAddLinkToChat = useEffectEvent(() => onAddTerminalLink !== undefined); + const addLinkToChat = useEffectEvent((text: string) => { + onAddTerminalLink?.(text); + }); + const preferredEditor = usePreferredEditor(serverConfig?.availableEditors ?? [])[0]; + const readPreferredEditor = useEffectEvent(() => preferredEditor); const readTerminalLabel = useEffectEvent(() => terminalLabel); const terminalFontFamily = useClientSettings((settings) => resolveTerminalFontPreference({ @@ -504,8 +589,8 @@ export function TerminalViewport({ // The surface listens from construction, so a right-click can land // while `create` is still awaiting WASM — before the handler below it // exists. The ref is only assigned once that setup has run. - onContextMenu: (event) => { - if (terminalRef.current) void showTerminalContextMenu(event); + onContextMenu: (event, link) => { + if (terminalRef.current) return showTerminalContextMenu(event, link); }, }; const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); @@ -649,7 +734,7 @@ export function TerminalViewport({ focusIfCurrent(requestId); }; - const showTerminalContextMenu = async (event: MouseEvent) => { + const showTerminalContextMenu = async (event: MouseEvent, link: { text: string } | null) => { if (!localApi || !terminalRef.current) return; // Own the gesture before anything async: leaving the default alive lets // the browser (or Electron's editing menu) answer with a Paste entry @@ -659,12 +744,30 @@ export function TerminalViewport({ clearSelectionAction(); const selectionAction = readSelectionAction(); const requestId = selectionActionRequestIdRef.current; + const linkText = link?.text ?? null; + const reveal = + linkText !== null && !isTerminalUrl(linkText) ? readFileManagerReveal() : null; + const canOpenPreview = + linkText !== null && isTerminalUrl(linkText) + ? await canOpenTerminalLinkInPreview(linkText, threadRef) + : false; let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( terminalContextMenuItems({ hasSelection: selectionAction !== null, canAddToChat: canAddSelectionToChat(), + ...(linkText !== null + ? { + link: { + text: linkText, + canOpenPreview, + canAddToChat: canAddLinkToChat(), + editorLabel: openInEditorMenuLabel(readPreferredEditor()), + ...(reveal ? { revealLabel: reveal.label } : {}), + }, + } + : {}), }), { x: event.clientX, y: event.clientY }, ); @@ -676,6 +779,56 @@ export function TerminalViewport({ if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } + if (linkText !== null) { + switch (clicked) { + case "open-terminal-link-preview": + try { + await openTerminalLinkInIntegratedBrowser({ + url: linkText, + threadRef, + openPreview, + fallbackToBrowser: () => openTerminalUrl(linkText, { forceBrowser: true }), + }); + } catch (error) { + reportOpenLinkFailure(error); + } + return; + case "open-terminal-link-browser": + openTerminalUrl(linkText, { forceBrowser: true }); + return; + case "add-terminal-link-to-chat": + case "add-terminal-path-to-chat": + if (canAddLinkToChat()) addLinkToChat(terminalLinkChatText(linkText, cwd)); + return; + case "copy-terminal-link": + case "copy-terminal-path": + try { + await writeTextToClipboard( + terminalLinkCopyText(linkText), + isTerminalUrl(linkText) ? "terminal link" : "terminal path", + ); + } catch (error) { + reportIfCurrent( + requestId, + error, + isTerminalUrl(linkText) + ? "Unable to copy terminal link" + : "Unable to copy terminal path", + ); + } + focusIfCurrent(requestId); + return; + case "open-terminal-path": + openTerminalPathLink(linkText); + return; + case "reveal-terminal-path": + if (reveal) { + const target = resolvePathLinkTarget(terminalLinkCopyText(linkText), cwd); + await runFileManagerPath(reveal.run, target, "Unable to reveal path"); + } + return; + } + } switch (clicked) { case "add-to-chat": if (selectionAction && canAddSelectionToChat()) { @@ -781,39 +934,43 @@ export function TerminalViewport({ return false; } - function handleLinkActivate(text: string, event: MouseEvent): void { + function openTerminalUrl(text: string, options: { forceBrowser: boolean }): void { const latestTerminal = terminalRef.current; if (!latestTerminal) return; - if (isTerminalUrl(text)) { - if (!localApi) { - writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); - return; - } - const fallbackToBrowser = () => { - void localApi.shell.openExternal(text).catch((error: unknown) => { - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open link", - ); - }); - }; - void openTerminalLinkInPreview({ - url: text, - threadRef, - openPreview, - fallbackToBrowser, - forceBrowser: event.metaKey || event.ctrlKey, - }).catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open link", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }); + if (!localApi) { + writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); return; } + const fallbackToBrowser = () => { + void localApi.shell.openExternal(text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + void openTerminalLinkInPreview({ + url: text, + threadRef, + openPreview, + fallbackToBrowser, + forceBrowser: options.forceBrowser, + }).catch(reportOpenLinkFailure); + } + + function reportOpenLinkFailure(error: unknown): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + + function openTerminalPathLink(text: string): void { + const latestTerminal = terminalRef.current; + if (!latestTerminal) return; const target = resolvePathLinkTarget(text, cwd); void (async () => { const result = await openTerminalPath(target); @@ -828,6 +985,14 @@ export function TerminalViewport({ })(); } + function handleLinkActivate(text: string, event: MouseEvent): void { + if (isTerminalUrl(text)) { + openTerminalUrl(text, { forceBrowser: event.metaKey || event.ctrlKey }); + return; + } + openTerminalPathLink(text); + } + function handleData(data: string): void { void (async () => { const result = await writeTerminal(data); @@ -1011,6 +1176,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalLink?: ((text: string) => void) | undefined; keybindings: ResolvedKeybindingsConfig; /** Prefer server-provided tab titles when present (e.g. active subprocess name). */ terminalLabelsById?: ReadonlyMap; @@ -1072,6 +1238,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal, onHeightChange, onAddTerminalContext, + onAddTerminalLink, keybindings, terminalLabelsById, terminalLaunchLocationsById, @@ -1542,6 +1709,7 @@ export default function ThreadTerminalDrawer({ : {})} onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} + {...(onAddTerminalLink ? { onAddTerminalLink } : {})} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} visible={visible} @@ -1572,6 +1740,7 @@ export default function ThreadTerminalDrawer({ : {})} onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} + {...(onAddTerminalLink ? { onAddTerminalLink } : {})} focusRequestId={focusRequestId} autoFocus visible={visible} diff --git a/apps/web/src/components/preview/fileExplorerLabel.test.ts b/apps/web/src/components/preview/fileExplorerLabel.test.ts index 3848c503c237..1fc8a0e75f76 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.test.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.test.ts @@ -1,11 +1,35 @@ import { describe, expect, it } from "vite-plus/test"; import { + fileManagerOpenNameForOs, + fileManagerRevealNameForKind, + fileManagerRevealNameForOs, revealInFileExplorerLabel, revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, + revealInFileExplorerLabelForManager, } from "./fileExplorerLabel"; +describe("file manager names", () => { + it.each([ + ["darwin", "Finder", "Finder"], + ["windows", "File Explorer", "File Explorer"], + ["linux", "File Manager", "Files"], + ] as const)("maps %s", (os, openName, revealName) => { + expect(fileManagerOpenNameForOs(os)).toBe(openName); + expect(fileManagerRevealNameForOs(os)).toBe(revealName); + }); + + it.each([ + ["finder", "Finder", "Reveal in Finder"], + ["file-explorer", "File Explorer", "Reveal in File Explorer"], + ["files", "Files", "Open Containing Folder"], + ] as const)("maps %s kind", (kind, revealName, label) => { + expect(fileManagerRevealNameForKind(kind)).toBe(revealName); + expect(revealInFileExplorerLabelForManager(revealName)).toBe(label); + }); +}); + describe("revealInFileExplorerLabel", () => { it.each([ ["MacIntel", "Reveal in Finder"], diff --git a/apps/web/src/components/preview/fileExplorerLabel.ts b/apps/web/src/components/preview/fileExplorerLabel.ts index 5229782721d0..72ddc5169a5d 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.ts @@ -1,5 +1,32 @@ import type { ExecutionEnvironmentPlatformOs, FileManagerRevealKind } from "@t3tools/contracts"; +export type FileManagerRevealName = "Finder" | "File Explorer" | "Files"; +export type FileManagerOpenName = "Finder" | "File Explorer" | "File Manager"; + +export function fileManagerOpenNameForOs(os: ExecutionEnvironmentPlatformOs): FileManagerOpenName { + if (os === "darwin") return "Finder"; + if (os === "windows") return "File Explorer"; + return "File Manager"; +} + +export function fileManagerRevealNameForOs( + os: ExecutionEnvironmentPlatformOs, +): FileManagerRevealName { + if (os === "darwin") return "Finder"; + if (os === "windows") return "File Explorer"; + return "Files"; +} + +export function fileManagerRevealNameForKind(kind: FileManagerRevealKind): FileManagerRevealName { + if (kind === "finder") return "Finder"; + if (kind === "file-explorer") return "File Explorer"; + return "Files"; +} + +export function revealInFileExplorerLabelForManager(name: FileManagerRevealName): string { + return name === "Files" ? "Open Containing Folder" : `Reveal in ${name}`; +} + export function revealInFileExplorerLabel(platform: string): string { const normalized = platform.toLowerCase(); if (normalized.includes("mac")) return "Reveal in Finder"; diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 23ef35a87829..fc43be8f7c29 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -38,53 +38,35 @@ interface OpenTerminalLinkInPreviewInput { readonly forceBrowser: boolean; } -/** - * Opens a terminal hyperlink where the "Open links in" setting says, unless a - * Cmd/Ctrl-click explicitly requests the system browser. - */ -export async function openTerminalLinkInPreview( - input: OpenTerminalLinkInPreviewInput, -): Promise { - const supportsPreview = - !input.forceBrowser && - isWebUrl(input.url) && - isPreviewSupportedInRuntime() && - input.threadRef.threadId.length > 0 && - (await resolveBrowserLinkTargetPreference()) === "app"; +export function canOpenTerminalLinkInPreview(url: string, threadRef: ScopedThreadRef): boolean { + return isWebUrl(url) && isPreviewSupportedInRuntime() && threadRef.threadId.length > 0; +} - if (!supportsPreview) { +export async function openTerminalLinkInIntegratedBrowser( + input: Omit, "forceBrowser">, +): Promise { + if (!canOpenTerminalLinkInPreview(input.url, input.threadRef)) { input.fallbackToBrowser(); return; } - const errorContext = { environmentId: input.threadRef.environmentId, threadId: input.threadRef.threadId, targetOrigin: new URL(input.url).origin, }; - const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { threadId: input.threadRef.threadId, url: input.url, - // Same reason as `openUrlInPreview`: this path handles its own result - // mapping, so the configured defaults are applied explicitly. viewport: browserDefaultOpenViewport(defaults), profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - console.error( - new TerminalLinkPreviewOpenError({ - ...errorContext, - cause: result.cause, - }), - ); + if (isAtomCommandInterrupted(result)) return; + console.error(new TerminalLinkPreviewOpenError({ ...errorContext, cause: result.cause })); input.fallbackToBrowser(); return; } @@ -92,3 +74,22 @@ export async function openTerminalLinkInPreview( applyPreviewServerSnapshot(input.threadRef, result.value); useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); } + +/** + * Opens a terminal hyperlink where the "Open links in" setting says, unless a + * Cmd/Ctrl-click explicitly requests the system browser. + */ +export async function openTerminalLinkInPreview( + input: OpenTerminalLinkInPreviewInput, +): Promise { + const supportsPreview = + !input.forceBrowser && + canOpenTerminalLinkInPreview(input.url, input.threadRef) && + (await resolveBrowserLinkTargetPreference()) === "app"; + + if (!supportsPreview) { + input.fallbackToBrowser(); + return; + } + await openTerminalLinkInIntegratedBrowser(input); +} diff --git a/apps/web/src/fileManagerReveal.test.ts b/apps/web/src/fileManagerReveal.test.ts new file mode 100644 index 000000000000..97730e667e78 --- /dev/null +++ b/apps/web/src/fileManagerReveal.test.ts @@ -0,0 +1,245 @@ +import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const toastManager = vi.hoisted(() => ({ add: vi.fn() })); + +vi.mock("./components/ui/toast", () => ({ + stackedThreadToast: (options: unknown) => options, + toastManager, +})); + +import { + fileManagerActionForPresentation, + resolveLiteralFilePath, + runFileManagerPath, +} from "./fileManagerReveal"; + +const localEnvironmentId = EnvironmentId.make("local-environment"); +const remoteEnvironmentId = EnvironmentId.make("remote-environment"); + +describe("resolveLiteralFilePath", () => { + it.each([ + ["POSIX absolute", "/workspace/file.txt", "/project", "/workspace/file.txt"], + [ + "Windows drive absolute", + String.raw`C:\workspace\file.txt`, + "/project", + String.raw`C:\workspace\file.txt`, + ], + [ + "Windows drive absolute with POSIX separators", + "C:/workspace/file.txt", + "/project", + "C:/workspace/file.txt", + ], + [ + "UNC absolute", + String.raw`\\server\share\file.txt`, + "/project", + String.raw`\\server\share\file.txt`, + ], + ["POSIX relative path", "src/file.txt", "/project", "/project/src/file.txt"], + ["literal tilde on POSIX", "~/config.json", "/project", "/project/~/config.json"], + ["literal colon on POSIX", "notes:1", "/project", "/project/notes:1"], + [ + "literal backslash on POSIX", + String.raw`notes\config.json`, + "/project", + String.raw`/project/notes\config.json`, + ], + [ + "Windows root with POSIX separators", + "src/file.txt", + "C:/project", + String.raw`C:\project\src\file.txt`, + ], + [ + "Windows drive-root relative with backslash", + String.raw`\report.pdf`, + "C:/project", + String.raw`C:\report.pdf`, + ], + [ + "Windows drive-root relative with POSIX separator", + "/report.pdf", + "C:/project", + String.raw`C:\report.pdf`, + ], + [ + "UNC root with POSIX separators", + "src/file.txt", + String.raw`\\server\share\project`, + String.raw`\\server\share\project\src\file.txt`, + ], + [ + "extended UNC volume-root relative with backslash", + String.raw`\report.pdf`, + String.raw`\\?\UNC\server\share\project`, + String.raw`\\?\UNC\server\share\report.pdf`, + ], + [ + "UNC volume-root relative with backslash", + String.raw`\report.pdf`, + String.raw`\\server\share\project`, + String.raw`\\server\share\report.pdf`, + ], + [ + "UNC volume-root relative with POSIX separator", + "/report.pdf", + String.raw`\\server\share\project`, + String.raw`\\server\share\report.pdf`, + ], + ] as const)("resolves %s", (_case, path, workspaceRoot, expected) => { + expect(resolveLiteralFilePath(path, workspaceRoot)).toBe(expected); + }); +}); + +describe("runFileManagerPath", () => { + beforeEach(() => { + toastManager.add.mockClear(); + }); + + it("ignores successful and interrupted commands", async () => { + const reportFailure = vi.fn(); + const successRun = vi.fn(async () => AsyncResult.success(undefined)); + const interruptedRun = vi.fn(async () => AsyncResult.failure(Cause.interrupt())); + + await runFileManagerPath( + successRun, + "/project/file.txt", + "Unable to reveal file", + reportFailure, + ); + await runFileManagerPath( + interruptedRun, + "/project/file.txt", + "Unable to reveal file", + reportFailure, + ); + + expect(successRun).toHaveBeenCalledWith("/project/file.txt"); + expect(interruptedRun).toHaveBeenCalledWith("/project/file.txt"); + expect(reportFailure).not.toHaveBeenCalled(); + expect(toastManager.add).not.toHaveBeenCalled(); + }); + + it("reports and displays a failed command result", async () => { + const error = new Error("Reveal failed"); + const result = AsyncResult.failure(Cause.fail(error)); + const run = vi.fn(async () => result); + const reportFailure = vi.fn(); + + await runFileManagerPath(run, "/project/file.txt", "Unable to reveal file", reportFailure); + + expect(reportFailure).toHaveBeenCalledWith(result.cause); + expect(toastManager.add).toHaveBeenCalledWith({ + type: "error", + title: "Unable to reveal file", + description: "Reveal failed", + }); + }); + + it("reports and displays a thrown command failure", async () => { + const error = new Error("Shell unavailable"); + const run = vi.fn(async () => { + throw error; + }); + const reportFailure = vi.fn(); + + await runFileManagerPath(run, "/project/file.txt", "Unable to reveal file", reportFailure); + + expect(reportFailure).toHaveBeenCalledWith(error); + expect(toastManager.add).toHaveBeenCalledWith({ + type: "error", + title: "Unable to reveal file", + description: "Shell unavailable", + }); + }); +}); + +function presentation( + environmentId: EnvironmentId, + input: { + readonly target?: "local" | "remote"; + readonly os?: "darwin" | "linux" | "windows" | "unknown"; + readonly kind?: "finder" | "file-explorer" | "files"; + readonly enabled?: boolean; + readonly availableEditors?: ReadonlyArray<"file-manager">; + } = {}, +): Parameters[1] { + const target = + input.target === "remote" + ? new PrimaryConnectionTarget({ + environmentId, + label: "remote", + httpBaseUrl: "https://remote.example", + wsBaseUrl: "wss://remote.example", + }) + : new PrimaryConnectionTarget({ + environmentId, + label: "local", + httpBaseUrl: "http://127.0.0.1", + wsBaseUrl: "ws://127.0.0.1", + }); + return { + entry: { target, profile: Option.none(), enabled: true }, + serverConfig: { + availableEditors: input.availableEditors ?? ["file-manager"], + environment: { platform: { os: input.os ?? "darwin" } }, + shellRevealInFileManager: input.enabled ?? true, + ...(input.kind === undefined ? {} : { shellRevealInFileManagerKind: input.kind }), + ...(input.target === "remote" + ? { remoteOpenTargets: [{ kind: "tailscale", host: "remote.example" }] } + : {}), + }, + }; +} + +describe("fileManagerActionForPresentation", () => { + it("keeps open available when reveal capability is unavailable", () => { + const command = vi.fn[2]>(); + const action = fileManagerActionForPresentation( + localEnvironmentId, + presentation(localEnvironmentId, { os: "linux", enabled: false }), + command, + ); + + expect(action).not.toBeNull(); + expect(action?.open.managerName).toBe("File Manager"); + expect(action?.reveal).toBeNull(); + }); + + it("labels a local Linux files action as opening the containing folder", () => { + const command = vi.fn[2]>(); + const action = fileManagerActionForPresentation( + localEnvironmentId, + presentation(localEnvironmentId, { os: "linux", kind: "files", enabled: true }), + command, + ); + + expect(action?.reveal).toMatchObject({ label: "Open Containing Folder" }); + }); + + it.each([ + [ + "the environment is remote", + remoteEnvironmentId, + presentation(remoteEnvironmentId, { target: "remote" }), + ], + [ + "the file-manager editor is unavailable", + localEnvironmentId, + presentation(localEnvironmentId, { availableEditors: [] }), + ], + ] as const)("returns null when %s", (_reason, environmentId, environmentPresentation) => { + const command = vi.fn[2]>(); + + expect( + fileManagerActionForPresentation(environmentId, environmentPresentation, command), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/fileManagerReveal.ts b/apps/web/src/fileManagerReveal.ts new file mode 100644 index 000000000000..4af9f90de9ee --- /dev/null +++ b/apps/web/src/fileManagerReveal.ts @@ -0,0 +1,287 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import * as Option from "effect/Option"; +import { useMemo } from "react"; + +import { resolveRemoteOpenState } from "./remoteOpen"; +import { + fileManagerRevealNameForKind, + fileManagerRevealNameForOs, + fileManagerOpenNameForOs, + type FileManagerOpenName, + type FileManagerRevealName, + revealInFileExplorerLabelForManager, +} from "./components/preview/fileExplorerLabel"; +import { environmentPresentations } from "./state/presentation"; +import { shellEnvironment } from "./state/shell"; +import { useAtomCommand } from "./state/use-atom-command"; +import { stackedThreadToast, toastManager } from "./components/ui/toast"; + +export type FileManagerActionResult = Awaited>; + +interface FileManagerRevealAction { + readonly label: string; + readonly run: (targetPath: string) => Promise; +} + +interface FileManagerOpenAction { + readonly managerName: FileManagerOpenName; + readonly run: (targetPath: string) => Promise; +} + +type FileManagerPathRun = (targetPath: string) => Promise>; + +export interface FileManagerAction { + readonly open: FileManagerOpenAction; + readonly reveal: FileManagerRevealAction | null; +} + +export async function runFileManagerPath( + run: FileManagerPathRun, + targetPath: string, + failureTitle: string, + onFailure?: (cause: unknown) => void, +): Promise { + try { + const result = await run(targetPath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + onFailure?.(result.cause); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + onFailure?.(cause); + toastManager.add( + stackedThreadToast({ + type: "error", + title: failureTitle, + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } +} + +function isAbsoluteFilePath(path: string): boolean { + return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"); +} + +function usesWindowsSeparators(path: string): boolean { + return /^[A-Za-z]:/.test(path) || path.startsWith("\\\\") || path.startsWith("//"); +} + +function trimTrailingSeparators(path: string, separator: "/" | "\\"): string { + const trimmed = separator === "/" ? path.replace(/\/+$/, "") : path.replace(/[\\/]+$/, ""); + return trimmed || separator; +} + +function windowsVolumeRootForWorkspaceRoot(workspaceRoot: string): string | null { + const normalizedRoot = workspaceRoot.replaceAll("/", "\\"); + const drive = /^([A-Za-z]:)(?:\\|$)/.exec(normalizedRoot); + if (drive !== null) return drive[1] ?? null; + + if (!normalizedRoot.startsWith("\\\\")) return null; + // `\\?\UNC\server\share` is the extended-length form; its volume root keeps the prefix. + const extendedUnc = /^\\\\\?\\UNC\\([^\\]+)\\([^\\]+)/i.exec(normalizedRoot); + if (extendedUnc !== null) return `\\\\?\\UNC\\${extendedUnc[1]}\\${extendedUnc[2]}`; + const [server, share] = normalizedRoot.slice(2).split("\\"); + return server && share ? `\\\\${server}\\${share}` : null; +} + +/** Resolves a literal file-tree path without interpreting terminal-link syntax. */ +export function resolveLiteralFilePath(path: string, workspaceRoot: string): string { + const windowsVolumeRoot = windowsVolumeRootForWorkspaceRoot(workspaceRoot); + if ( + windowsVolumeRoot !== null && + /^[\\/]/.test(path) && + !path.startsWith("\\\\") && + !path.startsWith("//") + ) { + return `${windowsVolumeRoot}\\${path.replace(/^[\\/]+/, "").replaceAll("/", "\\")}`; + } + if (isAbsoluteFilePath(path)) return path; + + const separator: "/" | "\\" = usesWindowsSeparators(workspaceRoot) ? "\\" : "/"; + const root = trimTrailingSeparators(workspaceRoot, separator).replaceAll("/", separator); + const relativePath = separator === "\\" ? path.replaceAll("/", "\\") : path; + return root === separator ? `${root}${relativePath}` : `${root}${separator}${relativePath}`; +} + +const EMPTY_FILE_MANAGER_OPEN_NAME_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-file-manager-open-name:empty"), +); +const EMPTY_FILE_MANAGER_REVEAL_NAME_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-file-manager-reveal-name:empty"), +); + +type FileManagerPresentation = Pick & { + readonly serverConfig: + | (Pick< + ServerConfig, + | "availableEditors" + | "remoteOpenTargets" + | "shellRevealInFileManager" + | "shellRevealInFileManagerKind" + > & { + readonly environment: { + readonly platform: Pick; + }; + }) + | null; +}; + +function sshAliasForPresentation(presentation: FileManagerPresentation): string | null { + const profile = Option.getOrNull(presentation.entry.profile); + return profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null; +} + +function isLocalEnvironment(presentation: FileManagerPresentation): boolean { + const remoteOpenState = resolveRemoteOpenState({ + target: presentation.entry.target, + sshAlias: sshAliasForPresentation(presentation), + remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, + isDesktopRenderer: typeof window !== "undefined" && window.desktopBridge !== undefined, + }); + return remoteOpenState.mode === "local-exec"; +} + +function fileManagerOpenNameForPresentation( + presentation: FileManagerPresentation, +): FileManagerOpenName | null { + const serverConfig = presentation.serverConfig; + if ( + serverConfig === null || + !serverConfig.availableEditors.includes("file-manager") || + !isLocalEnvironment(presentation) + ) { + return null; + } + + return fileManagerOpenNameForOs(serverConfig.environment.platform.os); +} + +function fileManagerRevealNameForPresentation( + presentation: FileManagerPresentation, +): FileManagerRevealName | null { + const serverConfig = presentation.serverConfig; + if ( + fileManagerOpenNameForPresentation(presentation) === null || + serverConfig?.shellRevealInFileManager !== true + ) { + return null; + } + + return serverConfig.shellRevealInFileManagerKind === undefined + ? fileManagerRevealNameForOs(serverConfig.environment.platform.os) + : fileManagerRevealNameForKind(serverConfig.shellRevealInFileManagerKind); +} + +function createFileManagerAction( + environmentId: EnvironmentId, + openManagerName: FileManagerOpenName, + revealManagerName: FileManagerRevealName | null, + openInEditor: (input: { + readonly environmentId: EnvironmentId; + readonly input: { + readonly cwd: string; + readonly editor: "file-manager"; + readonly reveal?: true; + }; + }) => Promise, +): FileManagerAction { + const reveal = + revealManagerName === null + ? null + : { + label: revealInFileExplorerLabelForManager(revealManagerName), + run: (targetPath: string) => + openInEditor({ + environmentId, + input: { cwd: targetPath, editor: "file-manager", reveal: true }, + }), + }; + + return { + open: { + managerName: openManagerName, + run: (targetPath) => + openInEditor({ + environmentId, + input: { cwd: targetPath, editor: "file-manager" }, + }), + }, + reveal, + }; +} + +export function fileManagerActionForPresentation( + environmentId: EnvironmentId, + presentation: FileManagerPresentation, + openInEditor: (input: { + readonly environmentId: EnvironmentId; + readonly input: { + readonly cwd: string; + readonly editor: "file-manager"; + readonly reveal?: true; + }; + }) => Promise, +): FileManagerAction | null { + const openManagerName = fileManagerOpenNameForPresentation(presentation); + if (openManagerName === null) { + return null; + } + return createFileManagerAction( + environmentId, + openManagerName, + fileManagerRevealNameForPresentation(presentation), + openInEditor, + ); +} + +const openManagerNameAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const presentation = get(environmentPresentations.presentationAtom(environmentId)); + return presentation === null ? null : fileManagerOpenNameForPresentation(presentation); + }).pipe(Atom.withLabel(`web-file-manager-open-name:${environmentId}`)), +); +const revealManagerNameAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const presentation = get(environmentPresentations.presentationAtom(environmentId)); + return presentation === null ? null : fileManagerRevealNameForPresentation(presentation); + }).pipe(Atom.withLabel(`web-file-manager-reveal-name:${environmentId}`)), +); + +export function useFileManagerActionForEnvironment( + environmentId: EnvironmentId | null, +): FileManagerAction | null { + const openManagerName = useAtomValue( + environmentId === null ? EMPTY_FILE_MANAGER_OPEN_NAME_ATOM : openManagerNameAtom(environmentId), + ); + const revealManagerName = useAtomValue( + environmentId === null + ? EMPTY_FILE_MANAGER_REVEAL_NAME_ATOM + : revealManagerNameAtom(environmentId), + ); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + + return useMemo( + () => + environmentId === null || openManagerName === null + ? null + : createFileManagerAction(environmentId, openManagerName, revealManagerName, openInEditor), + [environmentId, openInEditor, openManagerName, revealManagerName], + ); +} diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 3c466378ba8b..c72b0439fcfb 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -173,6 +173,16 @@ describe("resolvePathLinkTarget", () => { resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"), ).toBe("C:/Users/julius/project/src/main.ts:12"); }); + + it("anchors root-relative paths to the Windows cwd's volume", () => { + expect(resolvePathLinkTarget("/foo/bar.ts:3", "C:\\Users\\julius\\project")).toBe( + "C:\\foo\\bar.ts:3", + ); + expect(resolvePathLinkTarget("\\report.pdf", "\\\\server\\share\\project")).toBe( + "\\\\server\\share\\report.pdf", + ); + expect(resolvePathLinkTarget("/foo/bar.ts", "/Users/julius/project")).toBe("/foo/bar.ts"); + }); }); describe("isTerminalLinkActivation", () => { diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 59e2082a7359..372c7d063101 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -105,6 +105,16 @@ function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); } +/** `C:` for a drive path, `\\\\server\\share` for a UNC path, else nothing. */ +function windowsVolumeRoot(cwd: string): string | null { + const normalized = cwd.replaceAll("/", "\\"); + const drive = /^([A-Za-z]:)(?:\\|$)/.exec(normalized); + if (drive?.[1]) return drive[1]; + if (!normalized.startsWith("\\\\")) return null; + const [server, share] = normalized.slice(2).split("\\"); + return server && share ? `\\\\${server}\\${share}` : null; +} + function joinPath(base: string, next: string, separator: "/" | "\\"): string { const cleanBase = base.replace(/[\\/]+$/, ""); if (separator === "\\") { @@ -210,6 +220,11 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; resolvedPath = joinPath(home, path.slice(2), separator); } + } else if (isWindowsPathStyle(cwd) && /^[\\/](?![\\/])/.test(path)) { + // A root-relative `/foo` in a Windows shell belongs to the cwd's volume, + // otherwise it lands on whatever drive the file manager or editor is on. + const volumeRoot = windowsVolumeRoot(cwd); + if (volumeRoot) resolvedPath = joinPath(volumeRoot, path.replace(/^[\\/]+/, ""), "\\"); } else if (!isAbsolutePath(path)) { const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; resolvedPath = joinPath(cwd, path, separator); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 59150ee320ae..a2902f7714a6 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -29,9 +29,41 @@ import { terminalWheelArrowData, terminalWheelDeltaRows, GhosttyTerminalSurface, + createTerminalLinkPinController, type GhosttyTerminalSurfaceOptions, } from "./surface"; +describe("terminal link pin controller", () => { + it("keeps only the newest pending menu pin", async () => { + const onChange = vi.fn(); + const controller = createTerminalLinkPinController(onChange); + const first = { text: "a", range: { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } } }; + const second = { text: "b", range: { start: { x: 1, y: 0 }, end: { x: 1, y: 0 } } }; + let closeFirst!: () => void; + controller.pin(first, new Promise((resolve) => (closeFirst = resolve))); + controller.pin(second, new Promise(() => undefined)); + closeFirst(); + await Promise.resolve(); + expect(controller.pinned).toEqual(second); + controller.clear(); + expect(controller.pinned).toBeNull(); + expect(onChange).toHaveBeenCalledTimes(3); + }); + + it("treats a right-click away from any link as superseding the open pin", async () => { + const onChange = vi.fn(); + const controller = createTerminalLinkPinController(onChange); + const link = { text: "a", range: { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } } }; + let closeFirst!: () => void; + controller.pin(link, new Promise((resolve) => (closeFirst = resolve))); + controller.pin(null, Promise.resolve()); + expect(controller.pinned).toBeNull(); + closeFirst(); + await Promise.resolve(); + expect(onChange).toHaveBeenCalledTimes(2); + }); +}); + vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ default: (await import("./vendor/ghostty-vt.wasm?inline")).default, })); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index be62ede4d065..4a902609c093 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -249,6 +249,42 @@ export interface TerminalLinkWithRange { readonly range: GhosttyCellRange; } +export function createTerminalLinkPinController(onChange?: () => void) { + let pinned: TerminalLinkWithRange | null = null; + let request = 0; + return { + get pinned() { + return pinned; + }, + pin(link: TerminalLinkWithRange | null, pending: Promise | void): void { + // A right-click away from any link supersedes a pin whose menu is still open. + if (link === null) { + request++; + if (pinned !== null) { + pinned = null; + onChange?.(); + } + return; + } + const current = ++request; + pinned = link; + onChange?.(); + Promise.resolve(pending).finally(() => { + if (current === request) { + pinned = null; + onChange?.(); + } + }); + }, + clear(): void { + if (pinned === null) return; + request++; + pinned = null; + onChange?.(); + }, + }; +} + function isSameTerminalLink( left: TerminalLinkWithRange, right: TerminalLinkWithRange | null, @@ -552,7 +588,10 @@ export interface GhosttyTerminalSurfaceOptions { * reporting. The host owns the menu, so it also owns preventing the browser * default — whose Paste entry can never reach a canvas terminal. */ - readonly onContextMenu?: (event: MouseEvent) => void; + readonly onContextMenu?: ( + event: MouseEvent, + link: TerminalLinkWithRange | null, + ) => void | Promise; } export class GhosttyTerminalSurface { @@ -566,6 +605,10 @@ export class GhosttyTerminalSurface { private readonly context: CanvasRenderingContext2D; private readonly core: GhosttyTerminalCore; private readonly options: GhosttyTerminalSurfaceOptions; + private readonly linkPinController = createTerminalLinkPinController(() => { + this.forceFullRender = true; + this.requestRender(); + }); private visible: boolean; private hasSize = false; private metrics: GhosttyCellMetrics; @@ -857,6 +900,7 @@ export class GhosttyTerminalSurface { fit(): boolean { if (this.disposed || !this.visible) return false; + if (this.linkPinController.pinned !== null) this.linkPinController.clear(); const width = this.mount.clientWidth; const height = this.mount.clientHeight; if (width <= 0 || height <= 0) { @@ -1606,7 +1650,9 @@ export class GhosttyTerminalSurface { event.preventDefault(); return; } - this.options.onContextMenu?.(event); + const link = this.linkAt(event.clientX, event.clientY); + const pending = this.options.onContextMenu?.(event, link); + this.linkPinController.pin(link, pending); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { @@ -1815,6 +1861,12 @@ export class GhosttyTerminalSurface { return; } this.snapshot = this.core.snapshot(); + // A pinned link is a viewport range: once new output or scrolling moves + // other text under it, the highlight would land on the wrong cells. + const pinned = this.linkPinController.pinned; + if (pinned !== null && !isSameTerminalLink(pinned, this.linkAtCell(pinned.range.start))) { + this.linkPinController.clear(); + } // A cursor that is not blinking right now must be drawn, never caught in an // off phase left behind by a blink that has since been turned off. if (!this.blinkEnabled()) this.cursorOn = true; @@ -1848,7 +1900,7 @@ export class GhosttyTerminalSurface { cursorOn: this.cursorOn, previousCursorY: this.renderedCursorY, focused: this.focused, - hoveredLinkRange: this.hoveredLink?.range ?? null, + hoveredLinkRange: (this.linkPinController.pinned ?? this.hoveredLink)?.range ?? null, ...(this.theme.selectionBackground !== undefined ? { selectionBackground: this.theme.selectionBackground } : {}), @@ -1938,6 +1990,14 @@ export class GhosttyTerminalSurface { originY: this.originY, }); if (!cell) return null; + return this.linkAtCell(cell); + } + + private linkAtCell(cell: { + readonly x: number; + readonly y: number; + }): TerminalLinkWithRange | null { + if (!this.snapshot) return null; const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); if (explicitHyperlink) { const start = { ...cell }; From df889a9fd8cd3ecf16c590229538169c1a78432f Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 17 Sep 2026 16:25:51 -0700 Subject: [PATCH 2/7] refactor(web): route chat markdown reveal through the shared file-manager hook --- apps/web/src/components/ChatMarkdown.tsx | 70 +++++-------------- .../preview/fileExplorerLabel.test.ts | 23 ------ .../components/preview/fileExplorerLabel.ts | 42 +++++------ 3 files changed, 37 insertions(+), 98 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3b398dd5100e..8e8d6813f3fd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -107,10 +107,6 @@ import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; -import { - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, -} from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -139,6 +135,7 @@ import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; +import { runFileManagerPath, useFileManagerActionForEnvironment } from "../fileManagerReveal"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -163,7 +160,6 @@ import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; -import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; @@ -1988,38 +1984,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ if (!onReveal) { return; } - void (async () => { - try { - const result = await onReveal(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { + void runFileManagerPath( + () => onReveal(), + targetPath, + "Unable to reveal file", + (cause) => reportMarkdownActionFailure( { operation: "reveal-file-in-file-manager", target: targetPath }, cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); + ), + ); }, [onReveal, targetPath]); const handleCopy = useCallback( @@ -2343,17 +2317,10 @@ function useChatMarkdownState({ const [preferredEditor] = usePreferredEditor(availableEditors); const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); - const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { - reportFailure: false, - }); - const revealInFileManagerLabel = - environmentId !== null && - serverConfig?.shellRevealInFileManager === true && - serverConfig.availableEditors.includes("file-manager") - ? serverConfig.shellRevealInFileManagerKind === undefined - ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) - : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) - : undefined; + const environmentFileManagerAction = useFileManagerActionForEnvironment(environmentId); + const fileManagerAction = canUseShellActions ? environmentFileManagerAction : null; + const fileManagerReveal = fileManagerAction?.reveal ?? null; + const revealInFileManagerLabel = fileManagerReveal?.label; const revealFileInFileManager = useCallback( (filePath: string) => { if (environmentId === null) { @@ -2363,12 +2330,12 @@ function useChatMarkdownState({ ), ); } - return openInEditor({ - environmentId, - input: { cwd: filePath, editor: "file-manager", reveal: true }, - }); + if (fileManagerReveal === null) { + return Promise.reject(new Error("File-manager reveal is unavailable.")); + } + return fileManagerReveal.run(filePath); }, - [environmentId, openInEditor], + [environmentId, fileManagerReveal], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { @@ -2623,7 +2590,7 @@ function useChatMarkdownState({ } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ - canUseShellActions && revealInFileManagerLabel !== undefined + canUseShellActions && fileManagerReveal !== null ? () => revealMarkdownFileInFileManager(fileLinkMeta) : undefined } @@ -2641,6 +2608,7 @@ function useChatMarkdownState({ }, [ canUseShellActions, + fileManagerReveal, fileLinkParentSuffixByPath, openFileInPanel, openInPreferredEditor, diff --git a/apps/web/src/components/preview/fileExplorerLabel.test.ts b/apps/web/src/components/preview/fileExplorerLabel.test.ts index 1fc8a0e75f76..76cc3fecca5c 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.test.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.test.ts @@ -5,8 +5,6 @@ import { fileManagerRevealNameForKind, fileManagerRevealNameForOs, revealInFileExplorerLabel, - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, revealInFileExplorerLabelForManager, } from "./fileExplorerLabel"; @@ -39,24 +37,3 @@ describe("revealInFileExplorerLabel", () => { expect(revealInFileExplorerLabel(platform)).toBe(expected); }); }); - -describe("revealInFileExplorerLabelForOs", () => { - it.each([ - ["darwin", "Reveal in Finder"], - ["windows", "Reveal in File Explorer"], - ["linux", "Reveal in Files"], - ["unknown", "Reveal in Files"], - ] as const)("maps %s to %s", (os, expected) => { - expect(revealInFileExplorerLabelForOs(os)).toBe(expected); - }); -}); - -describe("revealInFileExplorerLabelForKind", () => { - it.each([ - ["finder", "Reveal in Finder"], - ["file-explorer", "Reveal in File Explorer"], - ["files", "Reveal in Files"], - ] as const)("maps %s to %s", (kind, expected) => { - expect(revealInFileExplorerLabelForKind(kind)).toBe(expected); - }); -}); diff --git a/apps/web/src/components/preview/fileExplorerLabel.ts b/apps/web/src/components/preview/fileExplorerLabel.ts index 72ddc5169a5d..6b9fbaf5c92b 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.ts @@ -3,12 +3,25 @@ import type { ExecutionEnvironmentPlatformOs, FileManagerRevealKind } from "@t3t export type FileManagerRevealName = "Finder" | "File Explorer" | "Files"; export type FileManagerOpenName = "Finder" | "File Explorer" | "File Manager"; +function labelForFileManager(fileManagerName: FileManagerRevealName): string { + return fileManagerName === "Files" ? "Open Containing Folder" : `Reveal in ${fileManagerName}`; +} + +export function revealInFileExplorerLabel(platform: string): string { + const normalized = platform.toLowerCase(); + if (normalized.includes("mac")) return "Reveal in Finder"; + if (normalized.includes("win")) return "Reveal in File Explorer"; + return "Reveal in Files"; +} + +/** Environment-backed open names use the server's reported OS. */ export function fileManagerOpenNameForOs(os: ExecutionEnvironmentPlatformOs): FileManagerOpenName { if (os === "darwin") return "Finder"; if (os === "windows") return "File Explorer"; return "File Manager"; } +/** Environment-backed names use the server's reported OS rather than the navigator platform. */ export function fileManagerRevealNameForOs( os: ExecutionEnvironmentPlatformOs, ): FileManagerRevealName { @@ -17,34 +30,15 @@ export function fileManagerRevealNameForOs( return "Files"; } +/** Server-selected file-manager name, including Windows File Explorer reached from WSL. */ export function fileManagerRevealNameForKind(kind: FileManagerRevealKind): FileManagerRevealName { if (kind === "finder") return "Finder"; if (kind === "file-explorer") return "File Explorer"; return "Files"; } -export function revealInFileExplorerLabelForManager(name: FileManagerRevealName): string { - return name === "Files" ? "Open Containing Folder" : `Reveal in ${name}`; -} - -export function revealInFileExplorerLabel(platform: string): string { - const normalized = platform.toLowerCase(); - if (normalized.includes("mac")) return "Reveal in Finder"; - if (normalized.includes("win")) return "Reveal in File Explorer"; - return "Reveal in Files"; -} - -/** Same wording keyed by an environment's reported OS rather than a - navigator platform string, for actions that reveal on the server machine. */ -export function revealInFileExplorerLabelForOs(os: ExecutionEnvironmentPlatformOs): string { - if (os === "darwin") return "Reveal in Finder"; - if (os === "windows") return "Reveal in File Explorer"; - return "Reveal in Files"; -} - -/** Server-selected wording, including Windows File Explorer reached from WSL. */ -export function revealInFileExplorerLabelForKind(kind: FileManagerRevealKind): string { - if (kind === "finder") return "Reveal in Finder"; - if (kind === "file-explorer") return "Reveal in File Explorer"; - return "Reveal in Files"; +export function revealInFileExplorerLabelForManager( + fileManagerName: FileManagerRevealName, +): string { + return labelForFileManager(fileManagerName); } From 96f0f1f8aed3fbd4c1bf5302d43d937c0b7ffa40 Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 17 Sep 2026 16:36:42 -0700 Subject: [PATCH 3/7] chore(web): drop code comments from the terminal link menu change --- apps/web/src/components/ThreadTerminalDrawer.tsx | 2 -- apps/web/src/components/preview/fileExplorerLabel.ts | 3 --- apps/web/src/fileManagerReveal.ts | 2 -- apps/web/src/terminal-links.ts | 3 --- apps/web/src/terminal/ghostty/surface.ts | 3 --- 5 files changed, 13 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d0415cd4d9c4..94d8f8ce71ce 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -270,7 +270,6 @@ export type TerminalContextMenuAction = export function terminalLinkChatText(text: string, cwd: string): string { if (isTerminalUrl(text)) return text; - // Trim trailing separators, but keep the one that makes `C:\` a volume root. const path = splitFilePathPosition(text).path.replace(/(? void) { return pinned; }, pin(link: TerminalLinkWithRange | null, pending: Promise | void): void { - // A right-click away from any link supersedes a pin whose menu is still open. if (link === null) { request++; if (pinned !== null) { @@ -1861,8 +1860,6 @@ export class GhosttyTerminalSurface { return; } this.snapshot = this.core.snapshot(); - // A pinned link is a viewport range: once new output or scrolling moves - // other text under it, the highlight would land on the wrong cells. const pinned = this.linkPinController.pinned; if (pinned !== null && !isSameTerminalLink(pinned, this.linkAtCell(pinned.range.start))) { this.linkPinController.clear(); From d74adcbdd1e5328dd2d9482ab2ed7b1a2c918347 Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 21 Sep 2026 16:33:14 +0000 Subject: [PATCH 4/7] fix(web): adapt file context menus to manager labels --- apps/web/src/fileContextMenu.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 05425f205955..7ecd8b772f1f 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -15,8 +15,9 @@ import { useCallback, useMemo } from "react"; import { resolveDiffPathForWorkspace } from "./diffFileActions"; import { - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, + fileManagerRevealNameForKind, + fileManagerRevealNameForOs, + revealInFileExplorerLabelForManager, } from "~/components/preview/fileExplorerLabel"; import { readLocalApi } from "./localApi"; import { serverEnvironment } from "./state/server"; @@ -127,8 +128,12 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { serverConfig?.shellRevealInFileManager === true && serverConfig.availableEditors.includes("file-manager") ? serverConfig.shellRevealInFileManagerKind === undefined - ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) - : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) + ? revealInFileExplorerLabelForManager( + fileManagerRevealNameForOs(serverConfig.environment.platform.os), + ) + : revealInFileExplorerLabelForManager( + fileManagerRevealNameForKind(serverConfig.shellRevealInFileManagerKind), + ) : undefined, canOpenDefault: availableEditors.includes("file-manager"), editorIds: availableEditors, From 4f3e36cbb1939b77dce58b1f3b54cd989e398f1c Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 21 Sep 2026 16:39:35 +0000 Subject: [PATCH 5/7] fix(web): preserve terminal link targets --- .../components/ThreadTerminalDrawer.test.ts | 14 +++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 24 +++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 04ca2a2b936a..1b8ebc05c27f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -9,6 +9,7 @@ import { terminalThemeFromApp, terminalLinkChatText, terminalLinkCopyText, + terminalLinkTargetForEditor, } from "./ThreadTerminalDrawer"; describe("terminal selection menus", () => { @@ -129,6 +130,19 @@ describe("terminal link menus", () => { expect(terminalLinkCopyText("src/main.ts:12:3")).toBe("src/main.ts"); expect(terminalLinkCopyText("https://example.com:8080/a")).toBe("https://example.com:8080/a"); }); + + it("keeps Windows directory paths as directory links", () => { + expect(terminalLinkChatText("C:\\work\\", "C:\\repo")).toBe("C:\\work\\"); + }); + + it("strips positions only for the file manager", () => { + expect(terminalLinkTargetForEditor("/repo/src/main.ts:12:3", "file-manager")).toBe( + "/repo/src/main.ts", + ); + expect(terminalLinkTargetForEditor("/repo/src/main.ts:12:3", "vscode")).toBe( + "/repo/src/main.ts:12:3", + ); + }); }); describe("terminalThemeFromApp", () => { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 94d8f8ce71ce..98f5e3110d1f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { type ContextMenuItem, + type EditorId, type ProviderInstanceId, type ResolvedKeybindingsConfig, type ScopedThreadRef, @@ -58,7 +59,11 @@ import { type GhosttyTerminalSurfaceOptions, } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; -import { useOpenInPreferredEditor, usePreferredEditor } from "../editorPreferences"; +import { + resolveAndPersistPreferredEditor, + useOpenInPreferredEditor, + usePreferredEditor, +} from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; import { @@ -270,9 +275,17 @@ export type TerminalContextMenuAction = export function terminalLinkChatText(text: string, cwd: string): string { if (isTerminalUrl(text)) return text; - const path = splitFilePathPosition(text).path.replace(/(? openInPreferredEditor(target)); + const openTerminalPath = useEffectEvent((target: string) => { + const editor = resolveAndPersistPreferredEditor(serverConfig?.availableEditors ?? []); + return openInPreferredEditor(terminalLinkTargetForEditor(target, editor)); + }); const fileManagerAction = useFileManagerActionForEnvironment(environmentId); const readFileManagerReveal = useEffectEvent(() => fileManagerAction?.reveal ?? null); const openPreview = useAtomCommand(previewEnvironment.open, { From 1452948d5170dec8953daa3fa1299ae30b9d192a Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 21 Sep 2026 16:45:46 +0000 Subject: [PATCH 6/7] test(web): assert Windows terminal roots --- apps/web/src/components/ThreadTerminalDrawer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 1b8ebc05c27f..3b96dd50e8fe 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -125,7 +125,7 @@ describe("terminal link menus", () => { expect(terminalLinkChatText("src/main.ts:12:3", "/repo")).toContain("src/main.ts"); expect(terminalLinkChatText("/", "/repo")).toBe("/"); expect(terminalLinkChatText("src\\", "/repo")).toBe("[src](/repo/src)"); - expect(terminalLinkChatText("C:\\", "C:\\repo")).not.toContain("repo"); + expect(terminalLinkChatText("C:\\", "C:\\repo")).toBe("C:\\"); expect(terminalLinkChatText("https://example.com/a", "/repo")).toBe("https://example.com/a"); expect(terminalLinkCopyText("src/main.ts:12:3")).toBe("src/main.ts"); expect(terminalLinkCopyText("https://example.com:8080/a")).toBe("https://example.com:8080/a"); From 942827195e18f4402f112db39e40c40f585f1739 Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 17 Sep 2026 11:58:20 -0700 Subject: [PATCH 7/7] feat(web): reveal files and worktrees in the system file manager --- apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/LegacySidebar.tsx | 35 +++++++++++++++ apps/web/src/components/RightPanelTabs.tsx | 43 +++++++++++++++++-- apps/web/src/components/Sidebar.tsx | 23 +++++++++- .../src/components/files/FileBrowserPanel.tsx | 1 + .../components/threadActionMenu.logic.test.ts | 1 + .../src/components/threadActionMenu.logic.ts | 20 +++++++++ apps/web/src/fileManagerReveal.ts | 32 +++++++++++++- apps/web/src/hooks/useThreadActionMenu.ts | 23 +++++++++- 9 files changed, 174 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 77cd145609b6..3fc74cdebaaf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10371,6 +10371,7 @@ export default function ChatView(props: ChatViewProps) { maximized={rightPanelMaximized} surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} + workspaceRoot={activeWorkspaceRoot ?? null} activeSurfaceId={renderedRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} @@ -10429,6 +10430,7 @@ export default function ChatView(props: ChatViewProps) { } surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} + workspaceRoot={activeWorkspaceRoot ?? null} activeSurfaceId={renderedRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 7a13f01a4742..dde65355d40a 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -113,6 +113,7 @@ import { ensureLocalApi, readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; +import { openFileManagerPath, useFileManagerAction } from "../fileManagerReveal"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; @@ -214,6 +215,7 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { openWorkspaceMenuLabel } from "./threadActionMenu.logic"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -1133,6 +1135,7 @@ interface SidebarProjectItemProps { handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; + resolveFileManagerAction: ReturnType; threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; @@ -1154,6 +1157,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec handleNewThread, archiveThread, deleteThread, + resolveFileManagerAction, threadJumpLabelByKey, attachThreadListAutoAnimateRef, expandThreadListForProject, @@ -2239,6 +2243,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; + const fileManagerAction = threadWorkspacePath + ? resolveFileManagerAction(thread.environmentId) + : null; const clicked = await api.contextMenu.show( [ ...(thread.branch @@ -2248,6 +2255,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + ...(fileManagerAction + ? [ + { + id: "open-in-file-manager", + label: openWorkspaceMenuLabel( + fileManagerAction.open.managerName, + thread.worktreePath !== null, + ), + icon: "folder" as const, + }, + ] + : []), { id: "project-settings", label: "Project settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], @@ -2314,6 +2333,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; } + if (clicked === "open-in-file-manager") { + if (!threadWorkspacePath || !fileManagerAction) return; + await openFileManagerPath( + fileManagerAction, + threadWorkspacePath, + `Failed to open ${thread.worktreePath ? "worktree" : "project"}`, + ); + return; + } if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { const confirmed = await api.dialogs.confirm( @@ -2350,6 +2378,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec memberProjectByScopedKey, project.projectKey, project.workspaceRoot, + resolveFileManagerAction, router, setOpenMobile, startThreadRename, @@ -2887,6 +2916,7 @@ interface SidebarProjectsContentProps { handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; + resolveFileManagerAction: ReturnType; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; @@ -2929,6 +2959,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread, archiveThread, deleteThread, + resolveFileManagerAction, sortedProjects, expandedThreadListsByProject, activeRouteProjectKey, @@ -3080,6 +3111,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} + resolveFileManagerAction={resolveFileManagerAction} threadJumpLabelByKey={threadJumpLabelByKey} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} @@ -3113,6 +3145,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} + resolveFileManagerAction={resolveFileManagerAction} threadJumpLabelByKey={threadJumpLabelByKey} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} @@ -3149,6 +3182,7 @@ export default function LegacySidebar() { const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); + const resolveFileManagerAction = useFileManagerAction(); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, @@ -3800,6 +3834,7 @@ export default function LegacySidebar() { handleNewThread={handleNewThread} archiveThread={archiveThread} deleteThread={deleteThread} + resolveFileManagerAction={resolveFileManagerAction} sortedProjects={sortedProjects} expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index e1bc3f151fd9..8b7d40f9502c 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -69,6 +69,11 @@ import { useSharedPullRequestSummary, } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; +import { + resolveLiteralFilePath, + runFileManagerPath, + useFileManagerActionForEnvironment, +} from "~/fileManagerReveal"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; @@ -90,6 +95,7 @@ interface RightPanelTabsProps { surfaces: readonly RightPanelSurface[]; /** Fallback environment for surfaces that do not carry their own. */ environmentId: EnvironmentId | null; + workspaceRoot?: string | null; activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet; previewSessions: Readonly>; @@ -190,6 +196,7 @@ const SURFACE_UNAVAILABLE_HINTS = { type TabContextMenuAction = | "rename" | "copy-path" + | "reveal-in-file-manager" | "toggle-mute" | "close" | "close-others" @@ -202,6 +209,13 @@ function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; } +type FileSurface = Extract; +type WorkspaceFileSurface = FileSurface & { readonly attachment?: undefined }; + +function isWorkspaceFileSurface(surface: RightPanelSurface): surface is WorkspaceFileSurface { + return surface.kind === "file" && surface.attachment === undefined; +} + /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. @@ -832,6 +846,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); + const fileManagerAction = useFileManagerActionForEnvironment(props.environmentId); const tabListRef = useRef(null); const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); @@ -959,11 +974,22 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; + const isWorkspaceFile = isWorkspaceFileSurface(surface); + const revealAction = + isWorkspaceFile && + props.environmentId !== null && + props.workspaceRoot !== null && + props.workspaceRoot !== undefined + ? (fileManagerAction?.reveal ?? null) + : null; const items: ContextMenuItem[] = []; if (surface.kind === "device" && props.onRenameDevice) items.push({ id: "rename", label: "Rename" }); - if (surface.kind === "file" && surface.attachment === undefined) { + if (isWorkspaceFile) { items.push({ id: "copy-path", label: "Copy path" }); + if (revealAction !== null) { + items.push({ id: "reveal-in-file-manager", label: revealAction.label }); + } } const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); // Desktop overlay state only arrives once the preview manager has created @@ -1009,10 +1035,21 @@ export function RightPanelTabs(props: RightPanelTabsProps) { setRenamingDevice(surface.id); break; case "copy-path": - if (surface.kind === "file" && surface.attachment === undefined) { + if (isWorkspaceFileSurface(surface)) { props.onCopyFilePath(surface.relativePath); } break; + case "reveal-in-file-manager": + if ( + isWorkspaceFileSurface(surface) && + props.workspaceRoot !== null && + props.workspaceRoot !== undefined && + revealAction !== null + ) { + const targetPath = resolveLiteralFilePath(surface.relativePath, props.workspaceRoot); + await runFileManagerPath(revealAction.run, targetPath, "Unable to reveal file"); + } + break; case "toggle-mute": { // menuOverlay repeats the disabled gate above: the desktop tab must // exist before it can be addressed, however the menu was dismissed. @@ -1041,7 +1078,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { break; } }, - [props], + [fileManagerAction, props], ); const handleTabMouseDown = useCallback((event: ReactMouseEvent) => { if (event.button !== 1) return; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 23c55fe8ecce..a295030f5374 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -125,6 +125,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { openFileManagerPath, useFileManagerAction } from "../fileManagerReveal"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; @@ -151,7 +152,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; -import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { buildThreadActionMenuItems, openWorkspaceMenuLabel } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -2153,6 +2154,7 @@ export default function Sidebar() { const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); + const resolveFileManagerAction = useFileManagerAction(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); @@ -4051,6 +4053,9 @@ export default function Sidebar() { thread.worktreePath ?? projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ?? null; + const fileManagerAction = threadWorkspacePath + ? resolveFileManagerAction(thread.environmentId) + : null; // Un-settle pins the thread active until real activity clears the pin. // Environments without // the settlement capability get no lifecycle items at all. @@ -4102,6 +4107,12 @@ export default function Sidebar() { titleRegeneration: supportsTitleRegeneration, }, snoozePresets, + openWorkspaceLabel: fileManagerAction + ? openWorkspaceMenuLabel( + fileManagerAction.open.managerName, + thread.worktreePath !== null, + ) + : null, }), position, ), @@ -4205,6 +4216,15 @@ export default function Sidebar() { } copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); return; + case "open-in-file-manager": { + if (!threadWorkspacePath || !fileManagerAction) return; + await openFileManagerPath( + fileManagerAction, + threadWorkspacePath, + `Failed to open ${thread.worktreePath ? "worktree" : "project"}`, + ); + return; + } case "copy-branch": if (thread.branch) { copyBranchToClipboard(thread.branch, { branch: thread.branch }); @@ -4292,6 +4312,7 @@ export default function Sidebar() { openProjectSettings, projectScopeKey, projectByKey, + resolveFileManagerAction, serverConfigs, setProjectScopeKey, startThreadRename, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 69e8714a3298..5bf97f6ebaaf 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -228,6 +228,7 @@ export default function FileBrowserPanel({ description: "The chat isn't ready to accept input right now.", }); } + return; } } finally { context.close(); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 14965d4add58..a50d3e6f25ca 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -12,6 +12,7 @@ const baseState: ThreadActionMenuState = { isRegeneratingTitle: false, isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, + openWorkspaceLabel: null, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, ], diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index c37ec31f929c..f56afe3e4651 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -1,6 +1,8 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +import type { FileManagerOpenName } from "./preview/fileExplorerLabel"; + /** * Ids for the per-thread action menu. Snooze presets are dispatched as * `snooze:` so the union stays closed while the preset list @@ -24,6 +26,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "open-in-file-manager" | "archive" | "delete"; @@ -52,6 +55,14 @@ export interface ThreadActionMenuState { readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; + readonly openWorkspaceLabel: string | null; +} + +export function openWorkspaceMenuLabel( + fileManagerName: FileManagerOpenName, + hasWorktree: boolean, +): string { + return `Open ${hasWorktree ? "worktree" : "project"} in ${fileManagerName}`; } /** @@ -144,6 +155,15 @@ export function buildThreadActionMenuItems( { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, + ...(state.openWorkspaceLabel + ? [ + { + id: "open-in-file-manager" as const, + label: state.openWorkspaceLabel, + icon: "folder", + }, + ] + : []), { id: "project-settings", label: "Project settings", icon: "settings" }, // Archive removes the thread from the sidebar while keeping its // conversation under Settings > Archived threads — distinct from Settle diff --git a/apps/web/src/fileManagerReveal.ts b/apps/web/src/fileManagerReveal.ts index 721a86777d82..597a7fc26b07 100644 --- a/apps/web/src/fileManagerReveal.ts +++ b/apps/web/src/fileManagerReveal.ts @@ -8,7 +8,7 @@ import { import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import * as Option from "effect/Option"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { resolveRemoteOpenState } from "./remoteOpen"; import { @@ -73,6 +73,14 @@ export async function runFileManagerPath( } } +export function openFileManagerPath( + action: FileManagerAction, + targetPath: string, + failureTitle: string, +): Promise { + return runFileManagerPath(action.open.run, targetPath, failureTitle); +} + function isAbsoluteFilePath(path: string): boolean { return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"); } @@ -283,3 +291,25 @@ export function useFileManagerActionForEnvironment( [environmentId, openInEditor, openManagerName, revealManagerName], ); } + +export function useFileManagerAction(): (environmentId: EnvironmentId) => FileManagerAction | null { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + const actionsByEnvironment = useMemo(() => { + const actions = new Map(); + for (const [environmentId, presentation] of presentations ?? []) { + actions.set( + environmentId, + fileManagerActionForPresentation(environmentId, presentation, openInEditor), + ); + } + return actions; + }, [openInEditor, presentations]); + + return useCallback( + (environmentId: EnvironmentId) => actionsByEnvironment.get(environmentId) ?? null, + [actionsByEnvironment], + ); +} diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 8a24c5110fa7..69082636421b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -14,6 +14,7 @@ import { useCallback, useMemo } from "react"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, + openWorkspaceMenuLabel, type ThreadActionMenuId, } from "../components/threadActionMenu.logic"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -40,6 +41,7 @@ import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; import { useThreadActions } from "./useThreadActions"; +import { openFileManagerPath, useFileManagerActionForEnvironment } from "../fileManagerReveal"; function failureToast(title: string, error: unknown) { toastManager.add( @@ -69,6 +71,7 @@ export function useThreadActionMenu(input: { }) { const { threadRef, projectCwd, onStartRename } = input; const router = useRouter(); + const fileManagerAction = useFileManagerActionForEnvironment(threadRef?.environmentId ?? null); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -129,6 +132,7 @@ export function useThreadActionMenu(input: { // what the user is looking at. const thread = readThreadShell(threadRef); if (!thread) return; + const threadWorkspacePath = thread.worktreePath ?? projectCwd; const now = new Date(); const supports = { settlement: readEnvironmentSupportsSettlement(threadRef.environmentId), @@ -151,6 +155,13 @@ export function useThreadActionMenu(input: { isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, + openWorkspaceLabel: + threadWorkspacePath && fileManagerAction + ? openWorkspaceMenuLabel( + fileManagerAction.open.managerName, + thread.worktreePath !== null, + ) + : null, }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -241,7 +252,7 @@ export function useThreadActionMenu(input: { markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt); return; case "copy-path": { - const workspacePath = thread.worktreePath ?? projectCwd; + const workspacePath = threadWorkspacePath; if (!workspacePath) { toastManager.add( stackedThreadToast({ @@ -255,6 +266,15 @@ export function useThreadActionMenu(input: { copyPathToClipboard(workspacePath, { path: workspacePath }); return; } + case "open-in-file-manager": { + if (!threadWorkspacePath || !fileManagerAction) return; + await openFileManagerPath( + fileManagerAction, + threadWorkspacePath, + `Failed to open ${thread.worktreePath ? "worktree" : "project"}`, + ); + return; + } case "copy-branch": if (thread.branch) { copyBranchToClipboard(thread.branch, { branch: thread.branch }); @@ -332,6 +352,7 @@ export function useThreadActionMenu(input: { projectCwd, projectGroupingSettings, projects, + fileManagerAction, router, settleThread, snoozeThread,