diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 6cb00baf897d1..e77e52903a193 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/process"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as HttpClient from "effect/http/HttpClient"; @@ -227,6 +228,7 @@ it.effect.each([false, true])( Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {}), Layer.mock(ElectronMenu.ElectronMenu, {}), Layer.mock(ElectronShell.ElectronShell, {}), + Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}), Layer.mock(ElectronTheme.ElectronTheme, {}), Layer.mock(ElectronWindow.ElectronWindow, {}), Layer.mock(MacPermissions.MacPermissions, {}), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6a1b1c62e7471..f3614373f873b 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -44,6 +44,7 @@ import { openExternal, readMedia, openSystemSettings, + revealItemInFolder, checkSystemPermission, pasteAsText, probeRemoteEditors, @@ -152,6 +153,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(receiveProviderAuthCallback); yield* ipc.handle(cancelProviderAuthCallback); yield* ipc.handle(openSystemSettings); + yield* ipc.handle(revealItemInFolder); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); yield* ipc.handle(revealWindow); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..e4097ac9003cb 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -7,6 +7,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const READ_MEDIA_CHANNEL = "desktop:read-media"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; +export const REVEAL_ITEM_IN_FOLDER_CHANNEL = "desktop:reveal-item-in-folder"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 376f85d2baf9d..5614eb85d0f97 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -8,16 +8,21 @@ import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/process"; import { vi } from "vite-plus/test"; import type * as Electron from "electron"; -const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ +const { focusedWebContents, ownerWindow, showItemInFolder } = vi.hoisted(() => ({ focusedWebContents: vi.fn(), ownerWindow: vi.fn(), + showItemInFolder: vi.fn(), })); vi.mock("electron", () => ({ webContents: { getFocusedWebContents: focusedWebContents }, BrowserWindow: { fromWebContents: ownerWindow }, + shell: { showItemInFolder }, })); import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; @@ -25,6 +30,7 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; @@ -35,6 +41,7 @@ import { pickProjectFavicon, probeRemoteEditors, readMedia, + revealItemInFolder, syncHtmlFullscreen, } from "./window.ts"; @@ -285,6 +292,82 @@ describe("readMedia", () => { ); }); +describe("revealItemInFolder", () => { + const STOCK_FOLDER_VERB = + "\r\nHKEY_CLASSES_ROOT\\Directory\\shell\r\n (Default) REG_SZ none\r\n\r\n"; + + const reveal = ( + path: string, + input: { + readonly platform?: NodeJS.Platform; + readonly stdout?: string; + readonly exitCode?: number; + }, + ) => + Effect.sync(() => showItemInFolder.mockClear()).pipe( + Effect.andThen(revealItemInFolder.handler(path)), + Effect.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform: input.platform ?? "win32", + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: + input.stdout === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(input.stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ), + ), + ), + ); + + it.effect("reveals a local file natively on stock Windows", () => + Effect.gen(function* () { + const revealed = yield* reveal("C:/Users/t3/my project/a,b.ts", { + stdout: STOCK_FOLDER_VERB, + }); + + assert.isTrue(revealed); + assert.deepEqual(showItemInFolder.mock.calls, [["C:\\Users\\t3\\my project\\a,b.ts"]]); + }), + ); + + it.effect.each([ + ["on macOS", "/Users/t3/a.ts", { platform: "darwin", stdout: STOCK_FOLDER_VERB }], + ["for a WSL path", "\\\\wsl.localhost\\Ubuntu\\a.ts", { stdout: STOCK_FOLDER_VERB }], + [ + "with a replacement file manager", + "C:\\a.ts", + { stdout: " (Default) REG_SZ open\r\n" }, + ], + ["when the registry lookup fails", "C:\\a.ts", { exitCode: 1 }], + ] as const)("leaves the reveal to the server $0", ([, path, input]) => + Effect.gen(function* () { + const revealed = yield* reveal(path, input); + + assert.isFalse(revealed); + assert.equal(showItemInFolder.mock.calls.length, 0); + }), + ); +}); + describe("pickProjectFavicon", () => { const pickerLayer = ( pickFiles: () => Effect.Effect>, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index e0eea5c74c9ed..2d67db87493d1 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -23,6 +23,8 @@ import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; @@ -371,6 +373,45 @@ export const readMedia = DesktopIpc.makeIpcMethod({ }), }); +const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[\\/]/; + +// Stock Windows answers "none"; anything else may be a replacement file manager, which only the +// server reveal honors. spawner.string takes about 600 ms longer on Windows, so read stdout here. +const hasStockWindowsFolderVerb = Effect.fn("desktop.ipc.window.hasStockWindowsFolderVerb")( + function* () { + const env = yield* HostProcess.Environment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\reg.exe`, + ["query", "HKCR\\Directory\\shell", "/ve"], + { stdin: "ignore", stderr: "ignore" }, + ), + ); + const [stdout, exitCode] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + return exitCode === 0 && /REG_SZ\s+none\s*$/im.test(stdout); + }, + Effect.scoped, + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => false), +); + +export const revealItemInFolder = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.REVEAL_ITEM_IN_FOLDER_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.revealItemInFolder")(function* (path) { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.platform !== "win32" || !WINDOWS_DRIVE_PATH.test(path)) return false; + if (!(yield* hasStockWindowsFolderVerb())) return false; + Electron.shell.showItemInFolder(path.replaceAll("/", "\\")); + return true; + }), +}); + export const openSystemSettings = DesktopIpc.makeIpcMethod({ channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, payload: SystemSettingsPaneSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..153f66733e3cc 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -227,6 +227,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), + revealItemInFolder: (path: string) => + ipcRenderer.invoke(IpcChannels.REVEAL_ITEM_IN_FOLDER_CHANNEL, path), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0b0116be549cb..2964b94370920 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -151,7 +151,11 @@ import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; -import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; +import { + resolveRemoteOpenState, + revealInDesktopShell, + useRemoteOpenResolution, +} from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -650,10 +654,14 @@ function useChatMarkdownState({ ), ); } - return openInEditor({ - environmentId, - input: { cwd: filePath, editor: "file-manager", reveal: true }, - }); + return revealInDesktopShell(environmentId, filePath).then((revealed) => + revealed + ? AsyncResult.success(undefined) + : openInEditor({ + environmentId, + input: { cwd: filePath, editor: "file-manager", reveal: true }, + }), + ); }; const diffThemeName = resolveDiffThemeName(resolvedTheme); const handleCopy = (event: ReactClipboardEvent) => { diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index a19491adcd15e..c2d917370ace7 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -14,6 +14,7 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, + revealInDesktopShell, useRemoteCapableEditors, useRemoteOpenHint, useRemoteOpenState, @@ -264,12 +265,17 @@ export const OpenInPicker = memo(function OpenInPicker({ return true; } if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; + const reveal = revealFileManager && editor === "file-manager"; + if (reveal && (await revealInDesktopShell(environmentId, openInCwd))) { + setPreferredEditor(editor); + return true; + } const result = openInEditorMutation({ environmentId, input: { cwd: openInCwd, editor, - ...(revealFileManager && editor === "file-manager" ? { reveal: true } : {}), + ...(reveal ? { reveal: true } : {}), }, }); setPreferredEditor(editor); diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 1ea95c96de518..58542bd8a8919 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -11,6 +11,7 @@ import { revealInFileExplorerLabelForOs, } from "~/components/preview/fileExplorerLabel"; import { readLocalApi } from "./localApi"; +import { revealInDesktopShell } from "./remoteOpen"; import { serverEnvironment } from "./state/server"; import { shellEnvironment } from "./state/shell"; import { useAtomCommand } from "./state/use-atom-command"; @@ -119,6 +120,7 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { : (action.slice("editor:".length) as EditorId); if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return; + if (reveal && (await revealInDesktopShell(environmentId, absolutePath))) return; const result = await openInEditor({ environmentId, input: { cwd: absolutePath, editor, ...(reveal ? { reveal: true } : {}) }, diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 365ec4a3d50af..e93b123d3ddea 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -4,9 +4,33 @@ import { SshConnectionTarget, } from "@t3tools/client-runtime/connection"; import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { Option } from "effect"; +import { Atom } from "effect/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { resolveRemoteOpenState } from "./remoteOpen"; +const { readEnvironmentScope, presentations } = vi.hoisted(() => ({ + readEnvironmentScope: vi.fn(), + presentations: new Map(), +})); +vi.mock(import("./state/session"), async (importOriginal) => ({ + ...(await importOriginal()), + readEnvironmentScope, +})); +vi.mock(import("./state/presentation"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + environmentPresentations: { + ...actual.environmentPresentations, + presentationAtom: ((id: EnvironmentId) => + Atom.make( + presentations.get(id) ?? null, + )) as typeof actual.environmentPresentations.presentationAtom, + }, + }; +}); + +import { resolveRemoteOpenState, revealInDesktopShell } from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -23,6 +47,40 @@ const TAILSCALE_TARGETS = [ { kind: "mdns", host: "sol.local" }, ] as const; +describe("revealInDesktopShell", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const local = { + entry: { target: primaryTarget("http://127.0.0.1:8000"), profile: Option.none() }, + }; + const remote = { + entry: { + target: new RelayConnectionTarget({ environmentId, label: "sol" }), + profile: Option.none(), + }, + serverConfig: { remoteOpenTargets: TAILSCALE_TARGETS }, + }; + + it.each([ + ["reveals natively with the operate scope", local, true, true, true, 1], + ["leaves a connection without the operate scope to the server", local, false, true, false, 0], + ["reports a declined reveal so the server can try", local, true, false, false, 1], + ["leaves a connection that has not resolved yet to the server", null, true, true, false, 0], + ["leaves a remote connection to the server", remote, true, true, false, 0], + ])("%s", async (name, presentation, hasScope, bridgeResult, expected, bridgeCalls) => { + const id = EnvironmentId.make(`reveal-${name}`); + presentations.set(id, presentation); + readEnvironmentScope.mockReturnValue(hasScope); + const revealItemInFolder = vi.fn(async () => bridgeResult); + vi.stubGlobal("window", { desktopBridge: { revealItemInFolder } }); + + await expect(revealInDesktopShell(id, "C:\\repo\\a.ts")).resolves.toBe(expected); + expect(revealItemInFolder).toHaveBeenCalledTimes(bridgeCalls); + }); +}); + describe("resolveRemoteOpenState", () => { it("keeps exec behavior for a loopback primary target", () => { expect( diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 741f6f3c60cde..a543851a4896b 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -1,5 +1,6 @@ import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; import { + AuthOrchestrationOperateScope, REMOTE_CAPABLE_EDITOR_IDS, type EditorId, type EnvironmentId, @@ -13,7 +14,9 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { isLoopbackHostname } from "~/environments/primary/target"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { environmentPresentations } from "~/state/presentation"; +import { readEnvironmentScope } from "~/state/session"; export interface RemoteOpenHost { readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; @@ -177,6 +180,22 @@ export async function openRemoteEditorUrl(url: string): Promise { return true; } +/** Resolves false when this connection or the desktop app declines, so the server decides instead. */ +export async function revealInDesktopShell( + environmentId: EnvironmentId, + path: string, +): Promise { + // An unresolved connection defaults to local-exec; it may still turn out to be remote. + const resolution = appAtomRegistry.get(remoteOpenResolutionAtom(environmentId)); + if (!resolution.isResolved || resolution.state.mode !== "local-exec") return false; + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; + try { + return (await window.desktopBridge?.revealItemInFolder?.(path)) === true; + } catch { + return false; + } +} + const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 29be3d6d0219f..739e20030fe39 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1120,6 +1120,8 @@ export interface DesktopBridge { /** Reads http(s) media in the main process, outside renderer CORS and CSP; null on network failure. */ readMedia: (url: string) => Promise; openSystemSettings: (pane: SystemSettingsPane) => Promise; + /** Absent on older desktop builds. False when the caller should use the server's reveal. */ + revealItemInFolder?: (path: string) => Promise; checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; pasteAsText: () => Promise;