From 5d1a1e1b95875e0dddae0abb99da8e857d679744 Mon Sep 17 00:00:00 2001 From: lyn1dev <78355114+lyn1dev@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:06:44 +0100 Subject: [PATCH 1/6] perf(desktop): revealing a file on Windows no longer waits on PowerShell --- apps/desktop/src/app/DesktopApp.test.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.test.ts | 85 ++++++++++++++++++- apps/desktop/src/ipc/methods/window.ts | 41 +++++++++ apps/desktop/src/preload.ts | 2 + apps/web/src/components/ChatMarkdown.tsx | 18 ++-- apps/web/src/components/chat/OpenInPicker.tsx | 8 +- apps/web/src/fileContextMenu.ts | 5 ++ apps/web/src/remoteOpen.ts | 9 ++ packages/contracts/src/ipc.ts | 2 + 11 files changed, 168 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 8b5f9f712dc10..15f0385416894 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"; @@ -226,6 +227,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 db26c525c0015..add7a09c28e27 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, @@ -150,6 +151,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 6a149e24ae695..603759c7b43c1 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 2ddc672891755..665dcfa3de6f1 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, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -284,6 +291,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 9580b29b94bba..7f681ec32902f 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 7c44ec5133785..a10a98faa0811 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -215,6 +215,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 5e3c7ca96fd3b..f233102020b47 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -150,7 +150,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"; @@ -644,10 +648,14 @@ function useChatMarkdownState({ ), ); } - return openInEditor({ - environmentId, - input: { cwd: filePath, editor: "file-manager", reveal: true }, - }); + return revealInDesktopShell(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..f8b8236899948 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(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..5fbe32d088ddf 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, useRemoteOpenState } from "./remoteOpen"; import { serverEnvironment } from "./state/server"; import { shellEnvironment } from "./state/shell"; import { useAtomCommand } from "./state/use-atom-command"; @@ -89,6 +90,7 @@ function buildFileContextMenuItems(input: { export function useFileContextMenu(environmentId: EnvironmentId | null) { const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false }); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const remoteOpen = useRemoteOpenState(environmentId); const availableEditors = serverConfig?.availableEditors ?? []; const capabilities: FileContextMenuCapabilities = { @@ -119,6 +121,9 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { : (action.slice("editor:".length) as EditorId); if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return; + if (reveal && remoteOpen.mode === "local-exec" && (await revealInDesktopShell(absolutePath))) { + return; + } const result = await openInEditor({ environmentId, input: { cwd: absolutePath, editor, ...(reveal ? { reveal: true } : {}) }, diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 741f6f3c60cde..16e2ed86ed556 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -177,6 +177,15 @@ export async function openRemoteEditorUrl(url: string): Promise { return true; } +/** Resolves false when the desktop app declines, so the caller falls back to the server's reveal. */ +export async function revealInDesktopShell(path: string): Promise { + 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 afb87b14bc91f..0f85af90009ef 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1118,6 +1118,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; From 7b8f1c47dc4100c622623b762ccb65f094e73653 Mon Sep 17 00:00:00 2001 From: lyn1dev <78355114+lyn1dev@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:05:23 +0100 Subject: [PATCH 2/6] fix(web): native reveal respects the connection's operate permission --- apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 2 +- apps/web/src/fileContextMenu.ts | 6 +++- apps/web/src/remoteOpen.test.ts | 29 +++++++++++++++++-- apps/web/src/remoteOpen.ts | 10 +++++-- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f233102020b47..661a64ee0266d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -648,7 +648,7 @@ function useChatMarkdownState({ ), ); } - return revealInDesktopShell(filePath).then((revealed) => + return revealInDesktopShell(environmentId, filePath).then((revealed) => revealed ? AsyncResult.success(undefined) : openInEditor({ diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index f8b8236899948..c2d917370ace7 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -266,7 +266,7 @@ export const OpenInPicker = memo(function OpenInPicker({ } if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; const reveal = revealFileManager && editor === "file-manager"; - if (reveal && (await revealInDesktopShell(openInCwd))) { + if (reveal && (await revealInDesktopShell(environmentId, openInCwd))) { setPreferredEditor(editor); return true; } diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 5fbe32d088ddf..41db1340d18ff 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -121,7 +121,11 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { : (action.slice("editor:".length) as EditorId); if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return; - if (reveal && remoteOpen.mode === "local-exec" && (await revealInDesktopShell(absolutePath))) { + if ( + reveal && + remoteOpen.mode === "local-exec" && + (await revealInDesktopShell(environmentId, absolutePath)) + ) { return; } const result = await openInEditor({ diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 365ec4a3d50af..306c1c60280f7 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -4,9 +4,15 @@ import { SshConnectionTarget, } from "@t3tools/client-runtime/connection"; import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { resolveRemoteOpenState } from "./remoteOpen"; +const { readEnvironmentScope } = vi.hoisted(() => ({ readEnvironmentScope: vi.fn() })); +vi.mock(import("./state/session"), async (importOriginal) => ({ + ...(await importOriginal()), + readEnvironmentScope, +})); + +import { resolveRemoteOpenState, revealInDesktopShell } from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -23,6 +29,25 @@ const TAILSCALE_TARGETS = [ { kind: "mdns", host: "sol.local" }, ] as const; +describe("revealInDesktopShell", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ["reveals natively with the operate scope", true, true, true, 1], + ["leaves a connection without the operate scope to the server", false, true, false, 0], + ["reports a declined reveal so the server can try", true, false, false, 1], + ])("%s", async (_name, hasScope, bridgeResult, expected, bridgeCalls) => { + readEnvironmentScope.mockReturnValue(hasScope); + const revealItemInFolder = vi.fn(async () => bridgeResult); + vi.stubGlobal("window", { desktopBridge: { revealItemInFolder } }); + + await expect(revealInDesktopShell(environmentId, "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 16e2ed86ed556..ab0866c96dcd8 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, @@ -14,6 +15,7 @@ import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { isLoopbackHostname } from "~/environments/primary/target"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { environmentPresentations } from "~/state/presentation"; +import { readEnvironmentScope } from "~/state/session"; export interface RemoteOpenHost { readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; @@ -177,8 +179,12 @@ export async function openRemoteEditorUrl(url: string): Promise { return true; } -/** Resolves false when the desktop app declines, so the caller falls back to the server's reveal. */ -export async function revealInDesktopShell(path: string): Promise { +/** Resolves false when this connection or the desktop app declines, so the server decides instead. */ +export async function revealInDesktopShell( + environmentId: EnvironmentId, + path: string, +): Promise { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false; try { return (await window.desktopBridge?.revealItemInFolder?.(path)) === true; } catch { From 1560fd4037cefcd4c9b1a17947fc1f54b097ebf2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 10:01:35 +0000 Subject: [PATCH 3/6] fix(web): file context menu waits for the connection to resolve before a native reveal Co-Authored-By: Claude Opus 5.5 --- apps/web/src/fileContextMenu.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 41db1340d18ff..c2b308a5e8967 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -11,7 +11,7 @@ import { revealInFileExplorerLabelForOs, } from "~/components/preview/fileExplorerLabel"; import { readLocalApi } from "./localApi"; -import { revealInDesktopShell, useRemoteOpenState } from "./remoteOpen"; +import { revealInDesktopShell, useRemoteOpenResolution } from "./remoteOpen"; import { serverEnvironment } from "./state/server"; import { shellEnvironment } from "./state/shell"; import { useAtomCommand } from "./state/use-atom-command"; @@ -90,7 +90,7 @@ function buildFileContextMenuItems(input: { export function useFileContextMenu(environmentId: EnvironmentId | null) { const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false }); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const remoteOpen = useRemoteOpenState(environmentId); + const remoteOpen = useRemoteOpenResolution(environmentId); const availableEditors = serverConfig?.availableEditors ?? []; const capabilities: FileContextMenuCapabilities = { @@ -123,7 +123,8 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { if ( reveal && - remoteOpen.mode === "local-exec" && + remoteOpen.isResolved && + remoteOpen.state.mode === "local-exec" && (await revealInDesktopShell(environmentId, absolutePath)) ) { return; From bf7a5287a3d1a015fab1a92f0667a1bf363ecbd7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 10:13:47 +0000 Subject: [PATCH 4/6] fix(web): native reveal waits for a resolved local connection at every entry point Co-Authored-By: Claude Opus 5.5 --- apps/web/src/fileContextMenu.ts | 12 ++------- apps/web/src/remoteOpen.test.ts | 45 ++++++++++++++++++++++++++++----- apps/web/src/remoteOpen.ts | 4 +++ 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index c2b308a5e8967..58542bd8a8919 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -11,7 +11,7 @@ import { revealInFileExplorerLabelForOs, } from "~/components/preview/fileExplorerLabel"; import { readLocalApi } from "./localApi"; -import { revealInDesktopShell, useRemoteOpenResolution } from "./remoteOpen"; +import { revealInDesktopShell } from "./remoteOpen"; import { serverEnvironment } from "./state/server"; import { shellEnvironment } from "./state/shell"; import { useAtomCommand } from "./state/use-atom-command"; @@ -90,7 +90,6 @@ function buildFileContextMenuItems(input: { export function useFileContextMenu(environmentId: EnvironmentId | null) { const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false }); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const remoteOpen = useRemoteOpenResolution(environmentId); const availableEditors = serverConfig?.availableEditors ?? []; const capabilities: FileContextMenuCapabilities = { @@ -121,14 +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 && - remoteOpen.isResolved && - remoteOpen.state.mode === "local-exec" && - (await revealInDesktopShell(environmentId, absolutePath)) - ) { - 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 306c1c60280f7..e93b123d3ddea 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -4,13 +4,31 @@ import { SshConnectionTarget, } from "@t3tools/client-runtime/connection"; import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; +import { Option } from "effect"; +import { Atom } from "effect/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -const { readEnvironmentScope } = vi.hoisted(() => ({ readEnvironmentScope: vi.fn() })); +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"; @@ -34,16 +52,31 @@ describe("revealInDesktopShell", () => { 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", true, true, true, 1], - ["leaves a connection without the operate scope to the server", false, true, false, 0], - ["reports a declined reveal so the server can try", true, false, false, 1], - ])("%s", async (_name, hasScope, bridgeResult, expected, bridgeCalls) => { + ["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(environmentId, "C:\\repo\\a.ts")).resolves.toBe(expected); + await expect(revealInDesktopShell(id, "C:\\repo\\a.ts")).resolves.toBe(expected); expect(revealItemInFolder).toHaveBeenCalledTimes(bridgeCalls); }); }); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index ab0866c96dcd8..a543851a4896b 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -14,6 +14,7 @@ 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"; @@ -184,6 +185,9 @@ 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; From 04b5b94a745dc8a7a1b96d02cd3a2fd693fc97c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 21:20:20 +0000 Subject: [PATCH 5/6] test(desktop): drop the ChildProcessSpawner mock main already provides Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopApp.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index ac83fce2526de..10e0d1cd16c9b 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -4,7 +4,6 @@ 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"; @@ -233,7 +232,6 @@ 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, {}), From f7f3414098d8ec7f92c5eec335271fdd75a23d8c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 21:38:39 +0000 Subject: [PATCH 6/6] perf(server): reveal files on Windows with Explorer instead of PowerShell Move the fast reveal from a desktop-only IPC path into the server's ExternalLauncher, so every client revealing a file on a Windows host benefits. The server reads the Directory\shell verb with reg.exe (cached like the other probes) and, on stock Windows, starts explorer.exe /select,"" with verbatim arguments. A replacement file manager or a failed lookup keeps the existing PowerShell reveal. Co-authored-by: lyn1dev Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 - apps/desktop/src/ipc/channels.ts | 1 - apps/desktop/src/ipc/methods/window.test.ts | 85 +------------------ apps/desktop/src/ipc/methods/window.ts | 41 --------- apps/desktop/src/preload.ts | 2 - .../src/process/externalLauncher.test.ts | 68 +++++++++++++++ apps/server/src/process/externalLauncher.ts | 82 +++++++++++++++--- .../src/process/windowsVerbatimSpawn.ts | 29 +++++++ apps/web/src/components/ChatMarkdown.tsx | 18 ++-- apps/web/src/components/chat/OpenInPicker.tsx | 8 +- apps/web/src/fileContextMenu.ts | 2 - apps/web/src/remoteOpen.test.ts | 62 +------------- apps/web/src/remoteOpen.ts | 19 ----- packages/contracts/src/ipc.ts | 2 - 14 files changed, 178 insertions(+), 243 deletions(-) create mode 100644 apps/server/src/process/windowsVerbatimSpawn.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index b43d8db4ebf56..9f2f68be61520 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -45,7 +45,6 @@ import { openExternal, readMedia, openSystemSettings, - revealItemInFolder, checkSystemPermission, pasteAsText, probeRemoteEditors, @@ -155,7 +154,6 @@ 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 f4202c17e50b4..0e422f9bc7356 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -7,7 +7,6 @@ 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 5614eb85d0f97..376f85d2baf9d 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -8,21 +8,16 @@ 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, showItemInFolder } = vi.hoisted(() => ({ +const { focusedWebContents, ownerWindow } = 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"; @@ -30,7 +25,6 @@ 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"; @@ -41,7 +35,6 @@ import { pickProjectFavicon, probeRemoteEditors, readMedia, - revealItemInFolder, syncHtmlFullscreen, } from "./window.ts"; @@ -292,82 +285,6 @@ 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 012db11a9a568..53a1138d71a99 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -23,8 +23,6 @@ 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"; @@ -383,45 +381,6 @@ 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 af4daa3cf7b09..6414743ac3b2f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -257,8 +257,6 @@ 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/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 0f73ab4fb9c53..ba759a1ca7509 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -21,6 +21,7 @@ import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; +import { WindowsVerbatimSpawn } from "./windowsVerbatimSpawn.ts"; // Windows drive letters break mocked POSIX PATH splitting; these tests also require /bin/sh stubs. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; @@ -392,6 +393,73 @@ it.effect.skipIf(windowsHost)( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +const STOCK_FOLDER_VERB = + "\r\nHKEY_CLASSES_ROOT\\Directory\\shell\r\n (Default) REG_SZ none\r\n\r\n"; + +it.effect.each([ + ["selects the file with Explorer on stock Windows", { stdout: STOCK_FOLDER_VERB }, true], + [ + "keeps the PowerShell reveal for a replacement file manager", + { stdout: " (Default) REG_SZ open\r\n" }, + false, + ], + ["keeps the PowerShell reveal when the registry lookup fails", { exitCode: 1 }, false], +] as const)("%s", ([, registry, native]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.EXE"), ""); + const spawned: ChildProcess.StandardCommand[] = []; + const verbatim: Array]> = []; + const exit = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher + .launchEditor({ + editor: "file-manager", + cwd: "C:/Users/t3/my project/a,b.ts", + reveal: true, + }) + .pipe(Effect.exit); + }).pipe( + Effect.provideService(WindowsVerbatimSpawn, (command, args) => + Effect.sync(() => verbatim.push([command, args])), + ), + Effect.provide( + layerTest({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: "C:\\Windows" }, + onSpawn: (command) => spawned.push(command), + spawnResult: (command) => (command.command.endsWith("reg.exe") ? registry : undefined), + }), + ), + ); + + assert.deepEqual( + spawned.map((command) => command.command), + ["C:\\Windows\\System32\\reg.exe"], + ); + if (native) { + assert.isTrue(Exit.isSuccess(exit)); + assert.deepEqual(verbatim, [ + ["C:\\Windows\\explorer.exe", ['/select,"C:\\Users\\t3\\my project\\a,b.ts"']], + ]); + } else { + assert.deepEqual(verbatim, []); + // The PowerShell launch is reached; this host has no powershell.exe to run it. + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ExternalLauncher.ExternalLauncherCommandNotFoundError); + assert.equal( + (error as ExternalLauncher.ExternalLauncherCommandNotFoundError).command, + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + } + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + for (const wsl of [false, true]) { it.effect.skipIf(windowsHost)( "selects a file over D-Bus on " + (wsl ? "WSLg without interop PowerShell" : "Linux"), diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index aea3e2d1472f9..3b1ed43142ac0 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -34,6 +34,7 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { makeStaleWhileRevalidate } from "../environment/staleWhileRevalidate.ts"; +import { WindowsVerbatimSpawn } from "./windowsVerbatimSpawn.ts"; export { ExternalLauncherError, ExternalLauncherBrowserSpawnError, @@ -184,8 +185,12 @@ function escapePowerShellStringLiteral(input: string): string { return `'${input.replaceAll("'", "''")}'`; } +function resolveWindowsRoot(env: NodeJS.ProcessEnv): string { + return env.SYSTEMROOT || env.windir || String.raw`C:\Windows`; +} + function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string { - return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + return `${resolveWindowsRoot(env)}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; } function resolveWslPowerShellPath(): string { @@ -665,18 +670,67 @@ const tryRevealLinuxItem = Effect.fn("externalLauncher.tryRevealLinuxItem")( }), ); +// Stock Windows answers "none"; any other verb may be a replacement file manager, which only +// the PowerShell reveal honors. Reading stdout is about 600 ms faster than spawner.string here. +const hasStockWindowsFolderVerb = Effect.fn("externalLauncher.hasStockWindowsFolderVerb")( + function* () { + const env = yield* readBrowserLaunchEnv; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + `${resolveWindowsRoot(env)}\\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), +); + +// Explorer parses /select itself, so it needs the argument verbatim rather than Node's quoting. +const revealWindowsItem = Effect.fn("externalLauncher.revealWindowsItem")(function* ( + target: string, +) { + const command = `${resolveWindowsRoot(yield* readBrowserLaunchEnv)}\\explorer.exe`; + const args = [`/select,"${normalizeWindowsFileManagerPath(target)}"`]; + yield* (yield* WindowsVerbatimSpawn)(command, args).pipe( + Effect.mapError( + (cause) => + new ExternalLauncherEditorSpawnError({ + editor: "file-manager", + target, + command, + args, + cause, + }), + ), + ); +}); + const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* ( input: LaunchEditorInput, + hasStockFolderVerb: Effect.Effect, ) { const launch = yield* resolveEditorLaunch(input); - if ( - input.editor === "file-manager" && - input.reveal === true && - launch.command === "xdg-open" && - (yield* HostProcess.Platform) === "linux" && - (yield* tryRevealLinuxItem(launch.target)) - ) { - return; + if (input.editor === "file-manager" && input.reveal === true) { + const platform = yield* HostProcess.Platform; + if ( + platform === "linux" && + launch.command === "xdg-open" && + (yield* tryRevealLinuxItem(launch.target)) + ) { + return; + } + if (platform === "win32" && (yield* hasStockFolderVerb)) { + return yield* revealWindowsItem(launch.target); + } } yield* launchEditorProcess(launch); }); @@ -837,6 +891,12 @@ export const make = Effect.gen(function* () { ), EDITOR_DISCOVERY_CACHE_TTL, ); + const cachedStockWindowsFolderVerb = yield* makeStaleWhileRevalidate( + hasStockWindowsFolderVerb().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); // Forwarders retain the underlying launcher and discovery spans. return ExternalLauncher.of({ @@ -854,7 +914,9 @@ export const make = Effect.gen(function* () { ), launchEditor: Effect.fnUntraced( function* (input) { - return yield* provideCommandResolutionServices(launchEditor(input)); + return yield* provideCommandResolutionServices( + launchEditor(input, cachedStockWindowsFolderVerb), + ); }, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), diff --git a/apps/server/src/process/windowsVerbatimSpawn.ts b/apps/server/src/process/windowsVerbatimSpawn.ts new file mode 100644 index 0000000000000..15075c22455b9 --- /dev/null +++ b/apps/server/src/process/windowsVerbatimSpawn.ts @@ -0,0 +1,29 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect's spawner has no windowsVerbatimArguments option. +import * as NodeChildProcess from "node:child_process"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +export type WindowsVerbatimSpawner = ( + command: string, + args: ReadonlyArray, +) => Effect.Effect; + +const spawnDetachedVerbatim: WindowsVerbatimSpawner = (command, args) => + Effect.callback((resume) => { + const child = NodeChildProcess.spawn(command, args, { + detached: true, + stdio: "ignore", + windowsVerbatimArguments: true, + }); + child.once("error", (error) => resume(Effect.fail(error))); + child.once("spawn", () => { + child.unref(); + resume(Effect.void); + }); + }); + +export const WindowsVerbatimSpawn = Context.Reference( + "t3/process/WindowsVerbatimSpawn", + { defaultValue: () => spawnDetachedVerbatim }, +); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ca69ad1393a37..b19e815f5a8b0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -155,11 +155,7 @@ import { readLocalApi } from "../localApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { hostEnvironmentIdAtom } from "../state/primaryEnvironment"; import { cn } from "../lib/utils"; -import { - resolveRemoteOpenState, - revealInDesktopShell, - useRemoteOpenResolution, -} from "../remoteOpen"; +import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; @@ -670,14 +666,10 @@ function useChatMarkdownState({ ), ); } - return revealInDesktopShell(environmentId, filePath).then((revealed) => - revealed - ? AsyncResult.success(undefined) - : openInEditor({ - environmentId, - input: { cwd: filePath, editor: "file-manager", reveal: true }, - }), - ); + return 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 a2607c3c75f89..d61a1a4a6063a 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -14,7 +14,6 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, - revealInDesktopShell, useRemoteCapableEditors, useRemoteOpenHint, useRemoteOpenState, @@ -266,17 +265,12 @@ 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, - ...(reveal ? { reveal: true } : {}), + ...(revealFileManager && editor === "file-manager" ? { reveal: true } : {}), }, }); setPreferredEditor(editor); diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 58542bd8a8919..1ea95c96de518 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -11,7 +11,6 @@ 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"; @@ -120,7 +119,6 @@ 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 ba52007577630..12149f98af238 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -4,33 +4,9 @@ import { SshConnectionTarget, } from "@t3tools/client-runtime/connection"; import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; -import { Option } from "effect"; -import { Atom } from "effect/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; -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 { remoteFallbackEditors, resolveRemoteOpenState, revealInDesktopShell } from "./remoteOpen"; +import { remoteFallbackEditors, resolveRemoteOpenState } from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -47,40 +23,6 @@ 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 e38e41f18986c..a62b203663f07 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -1,6 +1,5 @@ import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; import { - AuthOrchestrationOperateScope, REMOTE_CAPABLE_EDITOR_IDS, type EditorId, type EnvironmentId, @@ -15,10 +14,8 @@ import { browserClientOs, type BrowserIdentity } from "~/connection/clientMetada 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 { hostEnvironmentIdAtom } from "~/state/primaryEnvironment"; -import { readEnvironmentScope } from "~/state/session"; export interface RemoteOpenHost { readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; @@ -190,22 +187,6 @@ 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 5dfb3698ffac1..83ed8a813ece4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1140,8 +1140,6 @@ 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;