Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class ElectronShell extends Context.Service<
ElectronShell,
{
readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
readonly revealPath: (path: string) => Effect.Effect<void>;
readonly copyText: (text: string) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronShell") {}
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getSystemLocale,
getWindowFullscreenState,
openExternal,
revealPath,
probeRemoteEditors,
pickFolder,
pickThemeFiles,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ function makeTestLayer(input: {
input.openedExternalUrls?.push(url);
return true;
}),
revealPath: () => Effect.void,
copyText: () => Effect.void,
} satisfies ElectronShell.ElectronShell["Service"]),
electronThemeLayer,
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 64 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -106,6 +107,7 @@ import {
openUrlInPreview,
BrowserPreviewUnavailableError,
} from "../browser/openFileInPreview";
import { revealInFileExplorerLabel } from "./preview/fileExplorerLabel";

interface ChatMarkdownProps {
text: string;
Expand Down Expand Up @@ -822,6 +824,7 @@ interface MarkdownFileLinkProps {
onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;
onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
canRevealInFileManager: boolean;
className?: string | undefined;
}

Expand Down Expand Up @@ -1128,6 +1131,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
onOpen,
onOpenInPanel,
onOpenInBrowser,
canRevealInFileManager,
className,
}: MarkdownFileLinkProps) {
const handleOpenInEditor = useCallback(() => {
Expand Down Expand Up @@ -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<HTMLAnchorElement>) => {
event.preventDefault();
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -1293,7 +1325,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
);
}
},
[displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath],
[
canRevealInFileManager,
displayPath,
handleCopy,
handleOpenInBrowser,
handleOpenInEditor,
handleReveal,
onOpenInBrowser,
targetPath,
],
);

return (
Expand Down Expand Up @@ -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
);
}
Expand Down Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -1550,6 +1610,7 @@ function ChatMarkdown({
? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
: undefined
}
canRevealInFileManager={canRevealInFileManager}
className={className}
/>
);
Expand Down Expand Up @@ -1767,6 +1828,7 @@ function ChatMarkdown({
},
};
}, [
canRevealInFileManager,
cwd,
diffThemeName,
fileLinkParentSuffixByPath,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
});
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/localApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -30,6 +31,7 @@ function createBrowserLocalApi(): LocalApi {

window.open(url, "_blank", "noopener,noreferrer");
},
...(revealPath ? { revealPath } : {}),
},
contextMenu: {
show: async <T extends string>(
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,8 @@ export interface DesktopBridge {
position?: { x: number; y: number },
) => Promise<T | null>;
openExternal: (url: string) => Promise<boolean>;
/** Reveal a local file in Finder, Explorer, or the platform file manager. */
revealPath?: (path: string) => Promise<void>;
/**
* Probe this desktop machine for installed remote-capable editor CLIs
* (used for remote open-in-editor deep links). Optional: older desktop
Expand Down Expand Up @@ -1251,6 +1253,7 @@ export interface LocalApi {
};
shell: {
openExternal: (url: string) => Promise<void>;
revealPath?: (path: string) => Promise<void>;
};
contextMenu: {
show: <T extends string>(
Expand Down
Loading