diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..5d1127191154 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -2,14 +2,16 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; -const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ +const { openExternalMock, showItemInFolderMock, writeTextMock } = vi.hoisted(() => ({ openExternalMock: vi.fn(), + showItemInFolderMock: vi.fn(), writeTextMock: vi.fn(), })); vi.mock("electron", () => ({ shell: { openExternal: openExternalMock, + showItemInFolder: showItemInFolderMock, }, clipboard: { writeText: writeTextMock, @@ -21,9 +23,19 @@ import * as ElectronShell from "./ElectronShell.ts"; describe("ElectronShell", () => { beforeEach(() => { openExternalMock.mockReset(); + showItemInFolderMock.mockReset(); writeTextMock.mockReset(); }); + it.effect("reveals paths in the platform file manager", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + yield* electronShell.revealPath("/workspace/src/app.ts"); + + assert.deepEqual(showItemInFolderMock.mock.calls, [["/workspace/src/app.ts"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens safe external URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfebd0f..3e0ffcab9b11 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -43,6 +43,7 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly revealPath: (path: string) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -59,6 +60,7 @@ export const make = ElectronShell.of({ ), ), }), + revealPath: (path) => Effect.sync(() => Electron.shell.showItemInFolder(path)), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 37fd873a1b03..df93bb7af72d 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -37,6 +37,7 @@ import { getSystemLocale, getWindowFullscreenState, openExternal, + revealPath, probeRemoteEditors, pickFolder, pickThemeFiles, @@ -86,6 +87,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(revealPath); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 180e02810801..c73d79e79074 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const REVEAL_PATH_CHANNEL = "desktop:reveal-path"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 0c7e90b95072..d3838899978d 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -275,6 +275,16 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +export const revealPath = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.REVEAL_PATH_CHANNEL, + payload: Schema.String, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.revealPath")(function* (path) { + const shell = yield* ElectronShell.ElectronShell; + yield* shell.revealPath(path); + }), +}); + export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ee03141f2d82..9d5f8f84bf7b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -109,6 +109,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + revealPath: (path: string) => ipcRenderer.invoke(IpcChannels.REVEAL_PATH_CHANNEL, path), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..721249781305 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -276,6 +276,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + revealPath: () => Effect.void, copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, @@ -376,6 +377,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + revealPath: () => Effect.void, copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c3e1c5288da7..2caa0d801104 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -81,9 +81,10 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; -import { cn } from "../lib/utils"; +import { cn, isMacPlatform, isWindowsPlatform } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; import { useActiveEnvironmentId } from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; import { serverEnvironment } from "../state/server"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; @@ -106,6 +107,7 @@ import { openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; +import { revealInFileExplorerLabel } from "./preview/fileExplorerLabel"; interface ChatMarkdownProps { text: string; @@ -822,6 +824,7 @@ interface MarkdownFileLinkProps { onOpen: (targetPath: string) => Promise>; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; onOpenInBrowser?: (() => Promise>) | undefined; + canRevealInFileManager: boolean; className?: string | undefined; } @@ -1128,6 +1131,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onOpen, onOpenInPanel, onOpenInBrowser, + canRevealInFileManager, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { @@ -1250,6 +1254,22 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [targetPath], ); + const handleReveal = useCallback(() => { + const revealPath = readLocalApi()?.shell.revealPath; + if (!revealPath) return; + + void revealPath(iconPath).catch((cause) => { + reportMarkdownActionFailure({ operation: "reveal-file", target: iconPath }, cause); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + }); + }, [iconPath]); + const handleContextMenu = useCallback( async (event: ReactMouseEvent) => { event.preventDefault(); @@ -1265,6 +1285,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), + ...(canRevealInFileManager + ? ([ + { + id: "reveal", + label: revealInFileExplorerLabel(navigator.platform), + }, + ] as const) + : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, @@ -1279,6 +1307,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "reveal") { + handleReveal(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1293,7 +1325,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + canRevealInFileManager, + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleReveal, + onOpenInBrowser, + targetPath, + ], ); return ( @@ -1351,6 +1392,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && previous.onOpenInBrowser === next.onOpenInBrowser && + previous.canRevealInFileManager === next.canRevealInFileManager && previous.className === next.className ); } @@ -1378,7 +1420,25 @@ function ChatMarkdown({ }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); const environmentId = useActiveEnvironmentId(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const threadServerConfig = useAtomValue( + serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), + ); + const localPlatformOs = + typeof navigator === "undefined" + ? null + : isMacPlatform(navigator.platform) + ? "darwin" + : isWindowsPlatform(navigator.platform) + ? "windows" + : "linux"; + const canRevealInFileManager = + threadRef !== undefined && + threadRef.environmentId === primaryEnvironmentId && + threadServerConfig?.environment.platform.os === localPlatformOs && + typeof window !== "undefined" && + window.desktopBridge?.revealPath !== undefined; const openInPreferredEditor = useOpenInPreferredEditor( environmentId, serverConfig?.availableEditors ?? [], @@ -1550,6 +1610,7 @@ function ChatMarkdown({ ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } + canRevealInFileManager={canRevealInFileManager} className={className} /> ); @@ -1767,6 +1828,7 @@ function ChatMarkdown({ }, }; }, [ + canRevealInFileManager, cwd, diffThemeName, fileLinkParentSuffixByPath, diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9220252cb20e..451ed7ed0c68 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -116,11 +116,13 @@ describe("LocalApi", () => { it("delegates host capabilities and persistence to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); + const revealPath = vi.fn().mockResolvedValue(undefined); const getClientSettings = vi.fn().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); const setClientSettings = vi.fn().mockResolvedValue(undefined); testWindow().desktopBridge = { showContextMenu, pickFolder, + revealPath, getClientSettings, setClientSettings, } as unknown as DesktopBridge; @@ -133,11 +135,13 @@ describe("LocalApi", () => { requestConfirmDialogMock.mockReturnValue(undefined); await expect(api.dialogs.confirm("Install update?")).resolves.toBe(false); await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); + await api.shell.revealPath?.("/tmp/project/src/app.ts"); await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS); await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS); expect(showContextMenu).toHaveBeenCalledWith(items, undefined); expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp" }); + expect(revealPath).toHaveBeenCalledWith("/tmp/project/src/app.ts"); expect(getClientSettings).toHaveBeenCalledTimes(1); expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 863388106a3e..bf4680a80d5b 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -8,6 +8,7 @@ import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; let cachedApi: LocalApi | undefined; function createBrowserLocalApi(): LocalApi { + const revealPath = window.desktopBridge?.revealPath; return { dialogs: { pickFolder: async (options) => { @@ -30,6 +31,7 @@ function createBrowserLocalApi(): LocalApi { window.open(url, "_blank", "noopener,noreferrer"); }, + ...(revealPath ? { revealPath } : {}), }, contextMenu: { show: async ( diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93f074f7be56..2fefd7984e69 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1122,6 +1122,8 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** Reveal a local file in Finder, Explorer, or the platform file manager. */ + revealPath?: (path: string) => Promise; /** * Probe this desktop machine for installed remote-capable editor CLIs * (used for remote open-in-editor deep links). Optional: older desktop @@ -1251,6 +1253,7 @@ export interface LocalApi { }; shell: { openExternal: (url: string) => Promise; + revealPath?: (path: string) => Promise; }; contextMenu: { show: (