From 0e69aea035e9552af2e1025c1a1505753fe14c20 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:44:29 +0000 Subject: [PATCH 1/9] feat(web): open files in containing folder --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/process/externalLauncher.test.ts | 91 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 42 ++++++++- apps/server/src/server.test.ts | 27 ++++++ apps/server/src/ws.ts | 6 ++ apps/web/src/components/ChatMarkdown.tsx | 75 ++++++++++++++- .../client-runtime/src/state/shellCommands.ts | 4 + packages/contracts/src/editor.ts | 5 + packages/contracts/src/rpc.ts | 13 ++- 9 files changed, 261 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370a..610e5a78c115 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, + [WS_METHODS.shellRevealInFileManager]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1ab6166e92a1..587477d606ee 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -94,6 +94,97 @@ it.effect("launches the default browser through the platform command", () => { ); }); +it.effect("reveals files with the linux file manager", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); + const commandPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.revealInFileManager({ path: "/tmp/project/src/index.ts" }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "xdg-open"); + assert.deepEqual(spawned.args, ["/tmp/project/src"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals files with Finder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); + const commandPath = path.join(binDir, "open"); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.revealInFileManager({ path: "/tmp/project/src/index.ts" }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "open"); + assert.deepEqual(spawned.args, ["-R", "/tmp/project/src/index.ts"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals files with Windows Explorer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.revealInFileManager({ path: "C:\\project\\src\\index.ts" }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "explorer"); + assert.deepEqual(spawned.args, ["/select,C:\\project\\src\\index.ts"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ec928f26fc3..16df2853c6f7 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -16,6 +16,7 @@ import { ExternalLauncherUnsupportedEditorError, type EditorId, type LaunchEditorInput, + type RevealInFileManagerInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -45,7 +46,7 @@ export { ExternalLauncherUnsupportedEditorError, isExternalLauncherError, } from "@t3tools/contracts"; -export type { LaunchEditorInput }; +export type { LaunchEditorInput, RevealInFileManagerInput }; interface EditorLaunch { readonly editor: EditorId; readonly target: string; @@ -337,6 +338,10 @@ export class ExternalLauncher extends Context.Service< * Launches the editor as a detached process so server startup is not blocked. */ readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; + /** Reveal a workspace file in the host file manager. */ + readonly revealInFileManager: ( + input: RevealInFileManagerInput, + ) => Effect.Effect; } >()("t3/process/externalLauncher") {} @@ -384,6 +389,33 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; }); +const resolveFileManagerRevealLaunch = Effect.fn( + "externalLauncher.resolveFileManagerRevealLaunch", +)(function* ( + input: RevealInFileManagerInput, +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + const path = yield* Path.Path; + const args = + platform === "darwin" + ? ["-R", input.path] + : platform === "win32" + ? [`/select,${input.path}`] + : [path.dirname(input.path)]; + + yield* Effect.annotateCurrentSpan({ + "externalLauncher.target": input.path, + "externalLauncher.platform": platform, + }); + + return { + editor: "file-manager", + target: input.path, + command: fileManagerCommandForPlatform(platform), + args, + }; +}); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -501,6 +533,14 @@ export const make = Effect.gen(function* () { ), ), ), + revealInFileManager: (input) => + provideCommandResolutionServices( + Effect.flatMap(resolveFileManagerRevealLaunch(input), (launch) => + launchEditorProcess(launch).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ), + ), }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f895..0e9fdb16b648 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5097,6 +5097,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc shell.revealInFileManager", () => + Effect.gen(function* () { + let revealedInput: { path: string } | null = null; + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + revealInFileManager: (input) => + Effect.sync(() => { + revealedInput = input; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.shellRevealInFileManager]({ + path: "/tmp/project/src/index.ts", + }), + ), + ); + + assert.deepEqual(revealedInput, { path: "/tmp/project/src/index.ts" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc shell.openInEditor errors", () => Effect.gen(function* () { const externalLauncherError = new ExternalLauncherCommandNotFoundError({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 56ea24a4a8b8..e25a05268725 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1831,6 +1831,12 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", }), + [WS_METHODS.shellRevealInFileManager]: (input) => + observeRpcEffect( + WS_METHODS.shellRevealInFileManager, + externalLauncher.revealInFileManager(input), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 53b043f3a8ff..4387b7098e89 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -88,6 +88,7 @@ import { serverEnvironment } from "../state/server"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; +import { shellEnvironment } from "../state/shell"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; @@ -781,6 +782,7 @@ function UncachedShikiCodeBlock({ interface MarkdownFileLinkProps { href: string; + filePath: string; targetPath: string; iconPath: string; displayPath: string; @@ -791,6 +793,7 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; + onRevealInFileManager: (filePath: string) => Promise>; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -1086,6 +1089,7 @@ function MarkdownExternalLinkContent({ const MarkdownFileLink = memo(function MarkdownFileLink({ href, + filePath, targetPath, iconPath, displayPath, @@ -1096,6 +1100,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + onRevealInFileManager, onOpenInBrowser, className, }: MarkdownFileLinkProps) { @@ -1134,6 +1139,38 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpen, targetPath]); + const handleRevealInFileManager = useCallback(() => { + void (async () => { + try { + const result = await onRevealInFileManager(filePath); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "open-file-in-folder", target: filePath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open folder", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure({ operation: "open-file-in-folder", target: filePath }, cause); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open folder", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [filePath, onRevealInFileManager]); + const handleOpenInFilePreview = useCallback(() => { if (!threadRef || !workspaceRelativePath) { handleOpenInEditor(); @@ -1231,6 +1268,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, + { id: "open-in-folder", label: "Open in folder" }, ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), @@ -1244,6 +1282,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor(); return; } + if (clicked === "open-in-folder") { + handleRevealInFileManager(); + return; + } if (clicked === "open-in-browser") { handleOpenInBrowser(); return; @@ -1262,7 +1304,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + targetPath, + ], ); return ( @@ -1306,6 +1356,7 @@ function areMarkdownFileLinkPropsEqual( ): boolean { return ( previous.href === next.href && + previous.filePath === next.filePath && previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.displayPath === next.displayPath && @@ -1316,6 +1367,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.onRevealInFileManager === next.onRevealInFileManager && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); @@ -1338,6 +1390,9 @@ function ChatMarkdown({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); + const revealInFileManager = useAtomCommand(shellEnvironment.revealInFileManager, { + reportFailure: false, + }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); const environmentId = useActiveEnvironmentId(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); @@ -1414,6 +1469,21 @@ function ChatMarkdown({ }, [openPreview, threadRef], ); + const revealMarkdownFileInFileManager = useCallback( + (filePath: string): Promise> => { + const targetEnvironmentId = threadRef?.environmentId ?? environmentId; + if (targetEnvironmentId === null) { + return Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("No environment is selected."))), + ); + } + return revealInFileManager({ + environmentId: targetEnvironmentId, + input: { path: filePath }, + }); + }, + [environmentId, revealInFileManager, threadRef?.environmentId], + ); const openMarkdownFileInPreview = useCallback( (path: string) => { if (!threadRef || preparedConnection._tag === "None") { @@ -1460,6 +1530,7 @@ function ChatMarkdown({ return ( ( label: "environment-data:shell:open-in-editor", tag: WS_METHODS.shellOpenInEditor, }), + revealInFileManager: createEnvironmentRpcCommand(runtime, { + label: "environment-data:shell:reveal-in-file-manager", + tag: WS_METHODS.shellRevealInFileManager, + }), }; } diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index d714a0e02664..0610e173154c 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -123,6 +123,11 @@ export const RemoteOpenTarget = Schema.Struct({ }); export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; +export const RevealInFileManagerInput = Schema.Struct({ + path: TrimmedNonEmptyString, +}); +export type RevealInFileManagerInput = typeof RevealInFileManagerInput.Type; + export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass()( "ExternalLauncherUnknownEditorError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 115fc8a13114..142669293e85 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,7 +2,11 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; +import { + ExternalLauncherError, + LaunchEditorInput, + RevealInFileManagerInput, +} from "./editor.ts"; import { AuthAccessStreamError, AuthAccessStreamEvent, @@ -204,6 +208,7 @@ export const WS_METHODS = { // Shell methods shellOpenInEditor: "shell.openInEditor", + shellRevealInFileManager: "shell.revealInFileManager", // Filesystem methods filesystemBrowse: "filesystem.browse", @@ -644,6 +649,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), }); +export const WsShellRevealInFileManagerRpc = Rpc.make(WS_METHODS.shellRevealInFileManager, { + payload: RevealInFileManagerInput, + error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), +}); + export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { payload: FilesystemBrowseInput, success: FilesystemBrowseResult, @@ -1022,6 +1032,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, + WsShellRevealInFileManagerRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, From dede450ae454f18837c839a9b23b6a1637b5c9e8 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:35:35 +0000 Subject: [PATCH 2/9] fix: address open in folder review feedback --- .../src/process/externalLauncher.test.ts | 4 +- apps/server/src/process/externalLauncher.ts | 48 +++++++++---------- apps/web/src/components/ChatMarkdown.tsx | 10 ++-- packages/contracts/src/rpc.ts | 6 +-- 4 files changed, 31 insertions(+), 37 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 587477d606ee..5b136591b91b 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -166,7 +166,7 @@ it.effect("reveals files with Windows Explorer", () => let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.revealInFileManager({ path: "C:\\project\\src\\index.ts" }); + yield* launcher.revealInFileManager({ path: "C:\\project files\\src\\index.ts" }); }).pipe( Effect.provide( testLayer({ @@ -181,7 +181,7 @@ it.effect("reveals files with Windows Explorer", () => assert.ok(spawned); assert.equal(spawned.command, "explorer"); - assert.deepEqual(spawned.args, ["/select,C:\\project\\src\\index.ts"]); + assert.deepEqual(spawned.args, [`/select,"C:\\project files\\src\\index.ts"`]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 16df2853c6f7..098907de28a9 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -389,32 +389,30 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; }); -const resolveFileManagerRevealLaunch = Effect.fn( - "externalLauncher.resolveFileManagerRevealLaunch", -)(function* ( - input: RevealInFileManagerInput, -): Effect.fn.Return { - const platform = yield* HostProcessPlatform; - const path = yield* Path.Path; - const args = - platform === "darwin" - ? ["-R", input.path] - : platform === "win32" - ? [`/select,${input.path}`] - : [path.dirname(input.path)]; - - yield* Effect.annotateCurrentSpan({ - "externalLauncher.target": input.path, - "externalLauncher.platform": platform, - }); +const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileManagerRevealLaunch")( + function* (input: RevealInFileManagerInput): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + const path = yield* Path.Path; + const args = + platform === "darwin" + ? ["-R", input.path] + : platform === "win32" + ? [`/select,"${input.path}"`] + : [path.dirname(input.path)]; + + yield* Effect.annotateCurrentSpan({ + "externalLauncher.target": input.path, + "externalLauncher.platform": platform, + }); - return { - editor: "file-manager", - target: input.path, - command: fileManagerCommandForPlatform(platform), - args, - }; -}); + return { + editor: "file-manager", + target: input.path, + command: fileManagerCommandForPlatform(platform), + args, + }; + }, +); const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4387b7098e89..fc76152de5e7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1394,7 +1394,8 @@ function ChatMarkdown({ reportFailure: false, }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); + const activeEnvironmentId = useActiveEnvironmentId(); + const environmentId = threadRef?.environmentId ?? activeEnvironmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, @@ -1471,18 +1472,17 @@ function ChatMarkdown({ ); const revealMarkdownFileInFileManager = useCallback( (filePath: string): Promise> => { - const targetEnvironmentId = threadRef?.environmentId ?? environmentId; - if (targetEnvironmentId === null) { + if (environmentId === null) { return Promise.resolve( AsyncResult.failure(Cause.fail(new Error("No environment is selected."))), ); } return revealInFileManager({ - environmentId: targetEnvironmentId, + environmentId, input: { path: filePath }, }); }, - [environmentId, revealInFileManager, threadRef?.environmentId], + [environmentId, revealInFileManager], ); const openMarkdownFileInPreview = useCallback( (path: string) => { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 142669293e85..2536778f5caf 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,11 +2,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { - ExternalLauncherError, - LaunchEditorInput, - RevealInFileManagerInput, -} from "./editor.ts"; +import { ExternalLauncherError, LaunchEditorInput, RevealInFileManagerInput } from "./editor.ts"; import { AuthAccessStreamError, AuthAccessStreamEvent, From d9e7c7f156075d877d886bc40589df6140beee74 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:03:35 +0000 Subject: [PATCH 3/9] fix(web): gate open in folder by environment --- apps/web/src/components/ChatMarkdown.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fc76152de5e7..838035d902ee 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -793,6 +793,7 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; + canRevealInFileManager: boolean; onRevealInFileManager: (filePath: string) => Promise>; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; @@ -1100,6 +1101,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + canRevealInFileManager, onRevealInFileManager, onOpenInBrowser, className, @@ -1267,8 +1269,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ try { const clicked = await api.contextMenu.show( [ + ...(canRevealInFileManager + ? ([{ id: "open-in-folder", label: "Open in folder" }] as const) + : []), { id: "open", label: "Open in editor" }, - { id: "open-in-folder", label: "Open in folder" }, ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), @@ -1305,6 +1309,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } }, [ + canRevealInFileManager, displayPath, handleCopy, handleOpenInBrowser, @@ -1367,6 +1372,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.canRevealInFileManager === next.canRevealInFileManager && previous.onRevealInFileManager === next.onRevealInFileManager && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className @@ -1393,14 +1399,14 @@ function ChatMarkdown({ const revealInFileManager = useAtomCommand(shellEnvironment.revealInFileManager, { reportFailure: false, }); - const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); const activeEnvironmentId = useActiveEnvironmentId(); const environmentId = threadRef?.environmentId ?? activeEnvironmentId; + const preparedConnection = usePreparedConnection(environmentId); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const openInPreferredEditor = useOpenInPreferredEditor( - environmentId, - serverConfig?.availableEditors ?? [], - ); + const availableEditors = serverConfig?.availableEditors ?? []; + const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); + const canRevealInFileManager = + preparedConnection._tag === "Some" && availableEditors.includes("file-manager"); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -1541,6 +1547,7 @@ function ChatMarkdown({ theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} + canRevealInFileManager={canRevealInFileManager} onRevealInFileManager={revealMarkdownFileInFileManager} onOpenInBrowser={ threadRef && @@ -1754,6 +1761,7 @@ function ChatMarkdown({ }, }; }, [ + canRevealInFileManager, cwd, diffThemeName, fileLinkParentSuffixByPath, From 39788e86e90b4b82905e135ca870a331c9c893b7 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:12:06 +0000 Subject: [PATCH 4/9] fix(web): capability-gate folder reveal --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + apps/web/src/components/ChatMarkdown.tsx | 20 +++++------ .../chat/fileLinkContextMenu.test.ts | 33 +++++++++++++++++++ .../components/chat/fileLinkContextMenu.ts | 25 ++++++++++++++ packages/contracts/src/environment.ts | 3 ++ 6 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/chat/fileLinkContextMenu.test.ts create mode 100644 apps/web/src/components/chat/fileLinkContextMenu.ts diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ee30d987591d..d41689915f2b 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -93,6 +93,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); + expect(second.capabilities.fileManagerReveal).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..d76a16f31d12 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + fileManagerReveal: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 838035d902ee..90059a942b97 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -51,6 +51,7 @@ import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; +import { buildFileLinkContextMenuItems } from "./chat/fileLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -1268,17 +1269,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ try { const clicked = await api.contextMenu.show( - [ - ...(canRevealInFileManager - ? ([{ id: "open-in-folder", label: "Open in folder" }] as const) - : []), - { id: "open", label: "Open in editor" }, - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - { id: "copy-relative", label: "Copy relative path" }, - { id: "copy-full", label: "Copy full path" }, - ] as const, + buildFileLinkContextMenuItems({ + canRevealInFileManager, + canOpenInBrowser: onOpenInBrowser !== undefined, + }), { x: event.clientX, y: event.clientY }, ); @@ -1406,7 +1400,9 @@ function ChatMarkdown({ const availableEditors = serverConfig?.availableEditors ?? []; const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); const canRevealInFileManager = - preparedConnection._tag === "Some" && availableEditors.includes("file-manager"); + preparedConnection._tag === "Some" && + serverConfig?.environment.capabilities.fileManagerReveal === true && + availableEditors.includes("file-manager"); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< diff --git a/apps/web/src/components/chat/fileLinkContextMenu.test.ts b/apps/web/src/components/chat/fileLinkContextMenu.test.ts new file mode 100644 index 000000000000..4e338b7d90dc --- /dev/null +++ b/apps/web/src/components/chat/fileLinkContextMenu.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildFileLinkContextMenuItems } from "./fileLinkContextMenu"; + +describe("chat file link context menu", () => { + it("puts Open in folder first when the environment supports it", () => { + expect( + buildFileLinkContextMenuItems({ + canRevealInFileManager: true, + canOpenInBrowser: false, + }), + ).toEqual([ + { id: "open-in-folder", label: "Open in folder" }, + { id: "open", label: "Open in editor" }, + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ]); + }); + + it("hides Open in folder when the environment does not support it", () => { + expect( + buildFileLinkContextMenuItems({ + canRevealInFileManager: false, + canOpenInBrowser: true, + }), + ).toEqual([ + { id: "open", label: "Open in editor" }, + { id: "open-in-browser", label: "Open in integrated browser" }, + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ]); + }); +}); diff --git a/apps/web/src/components/chat/fileLinkContextMenu.ts b/apps/web/src/components/chat/fileLinkContextMenu.ts new file mode 100644 index 000000000000..a67da6268275 --- /dev/null +++ b/apps/web/src/components/chat/fileLinkContextMenu.ts @@ -0,0 +1,25 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; + +export type FileLinkContextMenuAction = + | "open-in-folder" + | "open" + | "open-in-browser" + | "copy-relative" + | "copy-full"; + +export function buildFileLinkContextMenuItems(input: { + readonly canRevealInFileManager: boolean; + readonly canOpenInBrowser: boolean; +}): readonly ContextMenuItem[] { + return [ + ...(input.canRevealInFileManager + ? ([{ id: "open-in-folder", label: "Open in folder" }] as const) + : []), + { id: "open", label: "Open in editor" }, + ...(input.canOpenInBrowser + ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + : []), + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ]; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1777bcebc2f8..2f35ddd15f3b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -67,6 +67,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), + /** Server understands shell.revealInFileManager. Absent on older servers, + so clients hide the action instead of sending an unsupported RPC. */ + fileManagerReveal: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ From f5965c20834e1afffdd4bb51e92f02477d401bcd Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:40:49 +0000 Subject: [PATCH 5/9] fix: harden open in folder behavior --- .../src/process/externalLauncher.test.ts | 118 ++++++++++++++++-- apps/server/src/process/externalLauncher.ts | 45 +++++-- apps/web/src/components/ChatMarkdown.tsx | 19 ++- .../chat/fileLinkContextMenu.test.ts | 43 ++++++- .../components/chat/fileLinkContextMenu.ts | 21 +++- apps/web/src/markdown-links.test.ts | 21 ++++ apps/web/src/markdown-links.ts | 4 +- 7 files changed, 246 insertions(+), 25 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 5b136591b91b..33a61122e8bf 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -106,12 +106,12 @@ it.effect("reveals files with the linux file manager", () => let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.revealInFileManager({ path: "/tmp/project/src/index.ts" }); + yield* launcher.revealInFileManager({ path: "/tmp/project with spaces/src/index.ts" }); }).pipe( Effect.provide( testLayer({ platform: "linux", - env: { PATH: binDir }, + env: { PATH: binDir, DISPLAY: ":0" }, onSpawn: (command) => { spawned = command; }, @@ -121,11 +121,11 @@ it.effect("reveals files with the linux file manager", () => assert.ok(spawned); assert.equal(spawned.command, "xdg-open"); - assert.deepEqual(spawned.args, ["/tmp/project/src"]); + assert.deepEqual(spawned.args, ["/tmp/project with spaces/src"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("reveals files with Finder", () => +it.effect("opens the containing folder with Finder", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -137,7 +137,7 @@ it.effect("reveals files with Finder", () => let spawned: ChildProcess.StandardCommand | undefined; yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.revealInFileManager({ path: "/tmp/project/src/index.ts" }); + yield* launcher.revealInFileManager({ path: "/tmp/project with spaces/src/index.ts" }); }).pipe( Effect.provide( testLayer({ @@ -152,11 +152,11 @@ it.effect("reveals files with Finder", () => assert.ok(spawned); assert.equal(spawned.command, "open"); - assert.deepEqual(spawned.args, ["-R", "/tmp/project/src/index.ts"]); + assert.deepEqual(spawned.args, ["/tmp/project with spaces/src"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("reveals files with Windows Explorer", () => +it.effect("opens the containing folder with Windows Explorer", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -181,7 +181,8 @@ it.effect("reveals files with Windows Explorer", () => assert.ok(spawned); assert.equal(spawned.command, "explorer"); - assert.deepEqual(spawned.args, [`/select,"C:\\project files\\src\\index.ts"`]); + assert.deepEqual(spawned.args, [`C:\\project files\\src`]); + assert.equal(spawned.options.shell, false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -371,6 +372,107 @@ it.effect("rescans after an interrupted discovery instead of caching the interru ), ); }); +it.effect("does not advertise a file manager on headless Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-headless-linux-" }); + const commandPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("advertises a file manager in a Linux graphical session", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-linux-" }); + const commandPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ platform: "linux", env: { PATH: binDir, WAYLAND_DISPLAY: "wayland-0" } }), + ), + ); + + assert.equal(editors.includes("file-manager"), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects a direct file manager reveal on headless Linux", () => + Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const error = yield* launcher + .revealInFileManager({ path: "/tmp/project/src/index.ts" }) + .pipe(Effect.flip); + assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnsupportedEditorError); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), +); + +it.effect("does not advertise a file manager over SSH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ssh-macos-" }); + const commandPath = path.join(binDir, "open"); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir, SSH_CONNECTION: "client server" }, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise Explorer from a Windows service", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-windows-service-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { + PATH: binDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + SESSIONNAME: "Services", + }, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 098907de28a9..39ffccd085fa 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -107,6 +107,11 @@ const CommandLookupEnvConfig = Config.all({ Path: Config.string("Path").pipe(Config.option), path: Config.string("path").pipe(Config.option), PATHEXT: Config.string("PATHEXT").pipe(Config.option), + DISPLAY: Config.string("DISPLAY").pipe(Config.option), + WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), + SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), + SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), + SESSIONNAME: Config.string("SESSIONNAME").pipe(Config.option), }).pipe(Config.map(compactEnv)); const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); @@ -224,6 +229,30 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } +function hasGraphicalFileManagerSession( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): boolean { + if (env.SSH_CONNECTION?.trim() || env.SSH_TTY?.trim()) return false; + if (platform === "linux") { + return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim()); + } + if (platform === "win32") { + return env.SESSIONNAME?.trim().toLowerCase() !== "services"; + } + return true; +} + +function fileManagerFolderPath(platform: NodeJS.Platform, target: string, path: Path.Path): string { + if (platform !== "win32") return path.dirname(target); + + const normalized = target.replaceAll("/", "\\"); + const separatorIndex = normalized.lastIndexOf("\\"); + if (separatorIndex < 0) return "."; + if (separatorIndex === 2 && normalized[1] === ":") return normalized.slice(0, 3); + return normalized.slice(0, separatorIndex) || "\\"; +} + function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { switch (platform) { case "darwin": @@ -271,6 +300,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" for (const editor of EDITORS) { if (editor.commands === null) { + if (!hasGraphicalFileManagerSession(platform, env)) continue; const command = fileManagerCommandForPlatform(platform); if (yield* isCommandAvailable(command, { env })) { available.push(editor.id); @@ -390,15 +420,16 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }); const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileManagerRevealLaunch")( - function* (input: RevealInFileManagerInput): Effect.fn.Return { + function* ( + input: RevealInFileManagerInput, + ): Effect.fn.Return { const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; + if (!hasGraphicalFileManagerSession(platform, env)) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: "file-manager" }); + } const path = yield* Path.Path; - const args = - platform === "darwin" - ? ["-R", input.path] - : platform === "win32" - ? [`/select,"${input.path}"`] - : [path.dirname(input.path)]; + const args = [fileManagerFolderPath(platform, input.path, path)]; yield* Effect.annotateCurrentSpan({ "externalLauncher.target": input.path, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 90059a942b97..fe648479a8cf 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -51,7 +51,11 @@ import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; -import { buildFileLinkContextMenuItems } from "./chat/fileLinkContextMenu"; +import { + buildFileLinkContextMenuItems, + canRevealFileLinkInManager, + resolveFileLinkEnvironmentId, +} from "./chat/fileLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; @@ -85,6 +89,7 @@ import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; import { useActiveEnvironmentId } from "../state/entities"; +import { useEnvironment } from "../state/environments"; import { serverEnvironment } from "../state/server"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; @@ -1394,15 +1399,17 @@ function ChatMarkdown({ reportFailure: false, }); const activeEnvironmentId = useActiveEnvironmentId(); - const environmentId = threadRef?.environmentId ?? activeEnvironmentId; + const environmentId = resolveFileLinkEnvironmentId(threadRef?.environmentId, activeEnvironmentId); + const environment = useEnvironment(environmentId); const preparedConnection = usePreparedConnection(environmentId); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const availableEditors = serverConfig?.availableEditors ?? []; const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); - const canRevealInFileManager = - preparedConnection._tag === "Some" && - serverConfig?.environment.capabilities.fileManagerReveal === true && - availableEditors.includes("file-manager"); + const canRevealInFileManager = canRevealFileLinkInManager({ + connectionPhase: environment?.connection.phase, + supportsRevealRpc: serverConfig?.environment.capabilities.fileManagerReveal === true, + availableEditors, + }); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< diff --git a/apps/web/src/components/chat/fileLinkContextMenu.test.ts b/apps/web/src/components/chat/fileLinkContextMenu.test.ts index 4e338b7d90dc..d279fe1b6608 100644 --- a/apps/web/src/components/chat/fileLinkContextMenu.test.ts +++ b/apps/web/src/components/chat/fileLinkContextMenu.test.ts @@ -1,6 +1,47 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { buildFileLinkContextMenuItems } from "./fileLinkContextMenu"; +import { + buildFileLinkContextMenuItems, + canRevealFileLinkInManager, + resolveFileLinkEnvironmentId, +} from "./fileLinkContextMenu"; + +describe("file link environment", () => { + it("routes through the thread environment before the active environment", () => { + const threadEnvironmentId = EnvironmentId.make("thread-environment"); + expect( + resolveFileLinkEnvironmentId(threadEnvironmentId, EnvironmentId.make("active-environment")), + ).toBe(threadEnvironmentId); + }); + + it("falls back to the active environment without thread context", () => { + const activeEnvironmentId = EnvironmentId.make("active-environment"); + expect(resolveFileLinkEnvironmentId(undefined, activeEnvironmentId)).toBe(activeEnvironmentId); + }); + + it.each([ + { + connectionPhase: "reconnecting", + supportsRevealRpc: true, + availableEditors: ["file-manager"], + }, + { connectionPhase: "connected", supportsRevealRpc: false, availableEditors: ["file-manager"] }, + { connectionPhase: "connected", supportsRevealRpc: true, availableEditors: [] }, + ] as const)("hides reveal when support is incomplete", (input) => { + expect(canRevealFileLinkInManager(input)).toBe(false); + }); + + it("allows reveal only when connected with rpc and file manager support", () => { + expect( + canRevealFileLinkInManager({ + connectionPhase: "connected", + supportsRevealRpc: true, + availableEditors: ["file-manager"], + }), + ).toBe(true); + }); +}); describe("chat file link context menu", () => { it("puts Open in folder first when the environment supports it", () => { diff --git a/apps/web/src/components/chat/fileLinkContextMenu.ts b/apps/web/src/components/chat/fileLinkContextMenu.ts index a67da6268275..01c7869b884e 100644 --- a/apps/web/src/components/chat/fileLinkContextMenu.ts +++ b/apps/web/src/components/chat/fileLinkContextMenu.ts @@ -1,4 +1,4 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuItem, EditorId, EnvironmentId } from "@t3tools/contracts"; export type FileLinkContextMenuAction = | "open-in-folder" @@ -7,6 +7,25 @@ export type FileLinkContextMenuAction = | "copy-relative" | "copy-full"; +export function resolveFileLinkEnvironmentId( + threadEnvironmentId: EnvironmentId | undefined, + activeEnvironmentId: EnvironmentId | null, +): EnvironmentId | null { + return threadEnvironmentId ?? activeEnvironmentId; +} + +export function canRevealFileLinkInManager(input: { + readonly connectionPhase: string | undefined; + readonly supportsRevealRpc: boolean; + readonly availableEditors: ReadonlyArray; +}): boolean { + return ( + input.connectionPhase === "connected" && + input.supportsRevealRpc && + input.availableEditors.includes("file-manager") + ); +} + export function buildFileLinkContextMenuItems(input: { readonly canRevealInFileManager: boolean; readonly canOpenInBrowser: boolean; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc296138672..ee84b3cadf62 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -108,6 +108,27 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("resolves the exact file path used by open in folder", () => { + expect(resolveMarkdownFileLinkMeta("src/file%20name.ts:12:4", "/repo/project")).toMatchObject({ + filePath: "/repo/project/src/file name.ts", + targetPath: "/repo/project/src/file name.ts:12:4", + line: 12, + column: 4, + }); + expect(resolveMarkdownFileLinkMeta("/tmp/report.ts:9", "/repo/project")).toMatchObject({ + filePath: "/tmp/report.ts", + targetPath: "/tmp/report.ts:9", + line: 9, + }); + }); + + it("resolves missing paths without requiring browser-side file access", () => { + expect(resolveMarkdownFileLinkMeta("src/not-created-yet.ts", "/repo/project")).toMatchObject({ + filePath: "/repo/project/src/not-created-yet.ts", + workspaceRelativePath: "src/not-created-yet.ts", + }); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8ac..d81460c6486d 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -5,8 +5,8 @@ const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._ -]+(?:\/[A-Za-z0-9._ -]+)+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._ -]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish From be23cedeb92f5571073c030e2c7c060a0b40b276 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:44:17 +0000 Subject: [PATCH 6/9] fix: select files when revealing supported --- .../src/process/externalLauncher.test.ts | 71 ++++++++++++++++++- apps/server/src/process/externalLauncher.ts | 26 ++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 33a61122e8bf..e9b096440faf 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -125,7 +125,7 @@ it.effect("reveals files with the linux file manager", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("opens the containing folder with Finder", () => +it.effect("opens the containing folder when the Finder target is missing", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -156,7 +156,41 @@ it.effect("opens the containing folder with Finder", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("opens the containing folder with Windows Explorer", () => +it.effect("selects an existing file in Finder", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); + const commandPath = path.join(binDir, "open"); + const targetPath = path.join(binDir, "project with spaces", "src", "index.ts"); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); + yield* fileSystem.writeFileString(targetPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.revealInFileManager({ path: targetPath }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "open"); + assert.deepEqual(spawned.args, ["-R", targetPath]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("opens the containing folder when the Explorer target is missing", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -186,6 +220,39 @@ it.effect("opens the containing folder with Windows Explorer", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("selects an existing file in Windows Explorer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-file-manager-" }); + const targetPath = path.join(binDir, "project files", "src", "index.ts"); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); + yield* fileSystem.writeFileString(targetPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.revealInFileManager({ path: targetPath }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "explorer"); + assert.deepEqual(spawned.args, ["/select,", targetPath.replaceAll("/", "\\")]); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 39ffccd085fa..26d205332b10 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -243,16 +243,32 @@ function hasGraphicalFileManagerSession( return true; } +function normalizeWindowsFileManagerPath(target: string): string { + return target.replaceAll("/", "\\"); +} + function fileManagerFolderPath(platform: NodeJS.Platform, target: string, path: Path.Path): string { if (platform !== "win32") return path.dirname(target); - const normalized = target.replaceAll("/", "\\"); + const normalized = normalizeWindowsFileManagerPath(target); const separatorIndex = normalized.lastIndexOf("\\"); if (separatorIndex < 0) return "."; if (separatorIndex === 2 && normalized[1] === ":") return normalized.slice(0, 3); return normalized.slice(0, separatorIndex) || "\\"; } +function fileManagerRevealArgs( + platform: NodeJS.Platform, + target: string, + targetExists: boolean, + path: Path.Path, +): ReadonlyArray { + if (!targetExists) return [fileManagerFolderPath(platform, target, path)]; + if (platform === "darwin") return ["-R", target]; + if (platform === "win32") return ["/select,", normalizeWindowsFileManagerPath(target)]; + return [fileManagerFolderPath(platform, target, path)]; +} + function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { switch (platform) { case "darwin": @@ -422,14 +438,18 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileManagerRevealLaunch")( function* ( input: RevealInFileManagerInput, - ): Effect.fn.Return { + ): Effect.fn.Return { const platform = yield* HostProcessPlatform; const env = yield* readCommandLookupEnv; if (!hasGraphicalFileManagerSession(platform, env)) { return yield* new ExternalLauncherUnsupportedEditorError({ editor: "file-manager" }); } + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const args = [fileManagerFolderPath(platform, input.path, path)]; + const targetExists = yield* fileSystem + .exists(input.path) + .pipe(Effect.orElseSucceed(() => false)); + const args = fileManagerRevealArgs(platform, input.path, targetExists, path); yield* Effect.annotateCurrentSpan({ "externalLauncher.target": input.path, From 31f5484bc3c1d52b61222939a6d458e7ad66a4f9 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:15:49 +0000 Subject: [PATCH 7/9] fix: support WSL explorer and spaced markdown links --- .../src/process/externalLauncher.test.ts | 42 ++++++++++++ apps/server/src/process/externalLauncher.ts | 68 +++++++++++++++---- apps/web/src/components/ChatMarkdown.tsx | 19 +----- apps/web/src/markdown-links.test.ts | 23 +++++++ apps/web/src/markdown-links.ts | 20 ++++++ 5 files changed, 143 insertions(+), 29 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index e9b096440faf..7fd2510ef1f9 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -457,6 +457,48 @@ it.effect("does not advertise a file manager on headless Linux", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("uses Windows Explorer from WSL without WSLg", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-wsl-explorer-" }); + const commandPath = path.join(binDir, "explorer.exe"); + const targetPath = path.join(binDir, "project with spaces", "src", "index.ts"); + yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(commandPath, 0o755); + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); + yield* fileSystem.writeFileString(targetPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + const env = { PATH: binDir, WSL_DISTRO_NAME: "Ubuntu-22.04" }; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const availableEditors = yield* launcher.resolveAvailableEditors(); + yield* launcher.revealInFileManager({ path: targetPath }); + return availableEditors; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), true); + assert.ok(spawned); + assert.equal(spawned.command, "explorer.exe"); + assert.deepEqual(spawned.args, [ + "/select,", + `\\\\wsl.localhost\\Ubuntu-22.04${targetPath.replaceAll("/", "\\")}`, + ]); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("advertises a file manager in a Linux graphical session", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 26d205332b10..2e6adb8ec7f1 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -67,6 +67,7 @@ interface TargetPathAndPosition { } const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; +const WSL_DISTRO_NAME_PATTERN = /^\w(?:[\w .-]*\w)?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", "-NonInteractive", @@ -109,9 +110,12 @@ const CommandLookupEnvConfig = Config.all({ PATHEXT: Config.string("PATHEXT").pipe(Config.option), DISPLAY: Config.string("DISPLAY").pipe(Config.option), WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), + WSL_DISTRO_NAME: Config.string("WSL_DISTRO_NAME").pipe(Config.option), + WSL_INTEROP: Config.string("WSL_INTEROP").pipe(Config.option), SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), SESSIONNAME: Config.string("SESSIONNAME").pipe(Config.option), + container: Config.string("container").pipe(Config.option), }).pipe(Config.map(compactEnv)); const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); @@ -229,11 +233,29 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } +function resolveWslDistroName(env: NodeJS.ProcessEnv): string | undefined { + const distroName = env.WSL_DISTRO_NAME?.trim(); + return distroName && WSL_DISTRO_NAME_PATTERN.test(distroName) ? distroName : undefined; +} + +function shouldUseWindowsFileManagerFromWsl( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): boolean { + return ( + shouldUseWindowsBrowserFromWsl(platform, env) && + !env.DISPLAY?.trim() && + !env.WAYLAND_DISPLAY?.trim() && + resolveWslDistroName(env) !== undefined + ); +} + function hasGraphicalFileManagerSession( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): boolean { if (env.SSH_CONNECTION?.trim() || env.SSH_TTY?.trim()) return false; + if (shouldUseWindowsFileManagerFromWsl(platform, env)) return true; if (platform === "linux") { return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim()); } @@ -247,6 +269,12 @@ function normalizeWindowsFileManagerPath(target: string): string { return target.replaceAll("/", "\\"); } +function resolveWslFileManagerPath(target: string, env: NodeJS.ProcessEnv): string { + const distroName = resolveWslDistroName(env); + if (!distroName) return target; + return `\\\\wsl.localhost\\${distroName}${normalizeWindowsFileManagerPath(target)}`; +} + function fileManagerFolderPath(platform: NodeJS.Platform, target: string, path: Path.Path): string { if (platform !== "win32") return path.dirname(target); @@ -262,14 +290,21 @@ function fileManagerRevealArgs( target: string, targetExists: boolean, path: Path.Path, + env: NodeJS.ProcessEnv, ): ReadonlyArray { + if (shouldUseWindowsFileManagerFromWsl(platform, env)) { + const revealTarget = targetExists ? target : fileManagerFolderPath(platform, target, path); + const windowsTarget = resolveWslFileManagerPath(revealTarget, env); + return targetExists ? ["/select,", windowsTarget] : [windowsTarget]; + } if (!targetExists) return [fileManagerFolderPath(platform, target, path)]; if (platform === "darwin") return ["-R", target]; if (platform === "win32") return ["/select,", normalizeWindowsFileManagerPath(target)]; return [fileManagerFolderPath(platform, target, path)]; } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { +function fileManagerCommandForPlatform(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { + if (shouldUseWindowsFileManagerFromWsl(platform, env)) return "explorer.exe"; switch (platform) { case "darwin": return "open"; @@ -317,7 +352,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" for (const editor of EDITORS) { if (editor.commands === null) { if (!hasGraphicalFileManagerSession(platform, env)) continue; - const command = fileManagerCommandForPlatform(platform); + const command = fileManagerCommandForPlatform(platform, env); if (yield* isCommandAvailable(command, { env })) { available.push(editor.id); } @@ -427,11 +462,19 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const path = yield* Path.Path; + const target = shouldUseWindowsFileManagerFromWsl(platform, env) + ? path.resolve(input.cwd) + : input.cwd; return { editor: editorDef.id, - target: input.cwd, - command: fileManagerCommandForPlatform(platform), - args: [input.cwd], + target, + command: fileManagerCommandForPlatform(platform, env), + args: [ + shouldUseWindowsFileManagerFromWsl(platform, env) + ? resolveWslFileManagerPath(target, env) + : target, + ], }; }); @@ -446,20 +489,21 @@ const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileMa } const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const targetExists = yield* fileSystem - .exists(input.path) - .pipe(Effect.orElseSucceed(() => false)); - const args = fileManagerRevealArgs(platform, input.path, targetExists, path); + const target = shouldUseWindowsFileManagerFromWsl(platform, env) + ? path.resolve(input.path) + : input.path; + const targetExists = yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false)); + const args = fileManagerRevealArgs(platform, target, targetExists, path, env); yield* Effect.annotateCurrentSpan({ - "externalLauncher.target": input.path, + "externalLauncher.target": target, "externalLauncher.platform": platform, }); return { editor: "file-manager", - target: input.path, - command: fileManagerCommandForPlatform(platform), + target, + command: fileManagerCommandForPlatform(platform, env), args, }; }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fe648479a8cf..6e99012b75fd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -79,7 +79,8 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { - normalizeMarkdownLinkDestination, + extractMarkdownLinkHrefs, + normalizeMarkdownLinkHrefKey, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -805,7 +806,6 @@ interface MarkdownFileLinkProps { className?: string | undefined; } -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; @@ -884,21 +884,6 @@ function extractInlineCodeSpans(text: string): string[] { return spans; } -function extractMarkdownLinkHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); - if (!href) continue; - hrefs.push(href); - } - return hrefs; -} - -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; -} - const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index ee84b3cadf62..7a66234d0fff 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,12 +1,35 @@ import { describe, expect, it } from "vite-plus/test"; import { + extractMarkdownLinkHrefs, + normalizeMarkdownLinkDestination, + normalizeMarkdownLinkHrefKey, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, } from "./markdown-links"; +describe("extractMarkdownLinkHrefs", () => { + it("extracts angle-bracketed destinations containing spaces", () => { + const [href] = extractMarkdownLinkHrefs('[file]( "source")'); + + expect(href).toBe(""); + expect(normalizeMarkdownLinkHrefKey(href ?? "")).toBe( + normalizeMarkdownLinkHrefKey("src/file%20name.ts"), + ); + expect( + resolveMarkdownFileLinkMeta(normalizeMarkdownLinkDestination(href ?? ""), "/repo/project"), + ).toMatchObject({ + filePath: "/repo/project/src/file name.ts", + }); + }); + + it("continues to extract regular markdown destinations", () => { + expect(extractMarkdownLinkHrefs("[file](src/file%20name.ts)")).toEqual(["src/file%20name.ts"]); + }); +}); + describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index d81460c6486d..bf34926bc8d1 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -9,6 +9,7 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._ -]+(?:\/[A-Za-z0-9._ -]+)+(?:: const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._ -]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; +const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\s*\)/g; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ @@ -64,6 +65,15 @@ export function normalizeMarkdownLinkDestination(value: string): string { return unwrapMarkdownLinkDestination(value.trim()); } +export function extractMarkdownLinkHrefs(text: string): string[] { + const hrefs: string[] = []; + for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { + const href = match[1]?.trim(); + if (href) hrefs.push(href); + } + return hrefs; +} + function stripSearchAndHash(value: string): { path: string; hash: string } { const hashIndex = value.indexOf("#"); const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; @@ -108,6 +118,16 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n return `${target.path}${target.hash}`; } +export function normalizeMarkdownLinkHrefKey(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + try { + return encodeURI(rewrittenHref).replace(/%25(?=[0-9A-Fa-f]{2})/g, "%"); + } catch { + return rewrittenHref; + } +} + function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; From 862bde7b03f19fef167211e0fadfb75a922e6401 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:28:31 +0000 Subject: [PATCH 8/9] fix(web): parse parenthesized markdown links --- apps/web/package.json | 1 + apps/web/src/markdown-links.test.ts | 46 ++++++++- apps/web/src/markdown-links.ts | 154 ++++++++++++++++++++++++++-- pnpm-lock.yaml | 3 + 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 598feaec0ce9..7e0d5775a2e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "jszip": "3.10.1", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "micromark-util-decode-string": "^2.0.1", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 7a66234d0fff..fb9648d95236 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -14,7 +14,7 @@ describe("extractMarkdownLinkHrefs", () => { it("extracts angle-bracketed destinations containing spaces", () => { const [href] = extractMarkdownLinkHrefs('[file]( "source")'); - expect(href).toBe(""); + expect(href).toBe("src/file name.ts"); expect(normalizeMarkdownLinkHrefKey(href ?? "")).toBe( normalizeMarkdownLinkHrefKey("src/file%20name.ts"), ); @@ -28,6 +28,50 @@ describe("extractMarkdownLinkHrefs", () => { it("continues to extract regular markdown destinations", () => { expect(extractMarkdownLinkHrefs("[file](src/file%20name.ts)")).toEqual(["src/file%20name.ts"]); }); + + it("extracts and resolves destinations containing balanced parentheses", () => { + const hrefs = extractMarkdownLinkHrefs( + '[one](src/foo(bar).ts) [two](src/foo(bar(baz)).ts "source")', + ); + + expect(hrefs).toEqual(["src/foo(bar).ts", "src/foo(bar(baz)).ts"]); + expect(resolveMarkdownFileLinkMeta(hrefs[0], "/repo/project")).toMatchObject({ + filePath: "/repo/project/src/foo(bar).ts", + }); + }); + + it("matches renderer decoding and nested labels", () => { + const hrefs = extractMarkdownLinkHrefs( + String.raw`[escaped](src/foo\(bar\).ts) [nested [label]](src/foo(bar).ts (source))`, + ); + + expect(hrefs).toEqual(["src/foo(bar).ts", "src/foo(bar).ts"]); + }); + + it("handles images inside links and brackets in destinations", () => { + expect(extractMarkdownLinkHrefs("[outer ![alt](img.png)](src/foo(bar).ts)")).toEqual([ + "src/foo(bar).ts", + ]); + expect(extractMarkdownLinkHrefs("[file]()")).toEqual(["/tmp/foo[bar].ts"]); + }); + + it("ignores destinations with unbalanced parentheses", () => { + expect(extractMarkdownLinkHrefs("[file](src/foo(bar).ts")).toEqual([]); + }); + + it("recovers a valid link after a malformed destination", () => { + expect(extractMarkdownLinkHrefs("[broken](oops\n[file](src/file.ts)")).toEqual(["src/file.ts"]); + expect(extractMarkdownLinkHrefs("[bad](oops([good](src/file.ts)")).toEqual(["src/file.ts"]); + expect(extractMarkdownLinkHrefs(String.raw`[bad](oops\ [good](src/file.ts))`)).toEqual([ + "src/file.ts", + ]); + }); + + it("keeps malformed input parsing bounded", () => { + const malformed = `${"[x]( { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index bf34926bc8d1..8ee071ae5fd9 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,3 +1,5 @@ +import { decodeString } from "micromark-util-decode-string"; + import { formatWorkspaceRelativePath } from "./filePathDisplay"; import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; @@ -5,11 +7,13 @@ const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._ -]+(?:\/[A-Za-z0-9._ -]+)+(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._ -]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._ ()-]+(?:\/[A-Za-z0-9._ ()-]+)+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._ ()-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\s*\)/g; +const MARKDOWN_ESCAPABLE_CHARACTER_PATTERN = /^[!-/:-@[-`{-~]$/; +const MAX_MARKDOWN_DESTINATION_DEPTH = 32; +const MAX_MARKDOWN_TITLE_LENGTH = 1_024; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ @@ -65,12 +69,150 @@ export function normalizeMarkdownLinkDestination(value: string): string { return unwrapMarkdownLinkDestination(value.trim()); } +interface ParsedMarkdownDestination { + readonly href: string; + readonly end: number; +} + +function isMarkdownEscape(text: string, index: number): boolean { + return text[index] === "\\" && MARKDOWN_ESCAPABLE_CHARACTER_PATTERN.test(text[index + 1] ?? ""); +} + +function skipMarkdownWhitespace(text: string, start: number): number | null { + let index = start; + let lineBreaks = 0; + while (index < text.length && /[\t\n\r ]/.test(text[index] ?? "")) { + if (text[index] === "\n" || text[index] === "\r") { + lineBreaks += 1; + if (lineBreaks > 1) return null; + if (text[index] === "\r" && text[index + 1] === "\n") index += 1; + } + index += 1; + } + return index; +} + +function parseMarkdownLinkTitle(text: string, start: number): number | null { + const opener = text[start]; + if (opener !== '"' && opener !== "'" && opener !== "(") return null; + const closer = opener === "(" ? ")" : opener; + + const end = Math.min(text.length, start + 1 + MAX_MARKDOWN_TITLE_LENGTH); + for (let index = start + 1; index < end; index += 1) { + const character = text[index]; + if (isMarkdownEscape(text, index)) { + index += 1; + continue; + } + if (character === closer) return index + 1; + if (character === "\n" || character === "\r") { + const next = skipMarkdownWhitespace(text, index); + if (next === null) return null; + } + } + return null; +} + +function finishMarkdownDestination( + text: string, + destination: string, + start: number, +): ParsedMarkdownDestination | null { + const suffixStart = skipMarkdownWhitespace(text, start); + if (suffixStart === null) return null; + if (text[suffixStart] === ")") { + return { href: decodeString(destination), end: suffixStart }; + } + + const titleEnd = parseMarkdownLinkTitle(text, suffixStart); + if (titleEnd === null) return null; + const wrapperEnd = skipMarkdownWhitespace(text, titleEnd); + if (wrapperEnd === null || text[wrapperEnd] !== ")") return null; + return { href: decodeString(destination), end: wrapperEnd }; +} + +function parseMarkdownDestination(text: string, start: number): ParsedMarkdownDestination | null { + const destinationStart = skipMarkdownWhitespace(text, start); + if (destinationStart === null) return null; + + if (text[destinationStart] === "<") { + for (let index = destinationStart + 1; index < text.length; index += 1) { + const character = text[index]; + if (isMarkdownEscape(text, index)) { + index += 1; + continue; + } + if (character === ">") { + return finishMarkdownDestination(text, text.slice(destinationStart + 1, index), index + 1); + } + if (character === "<" || character === "\n" || character === "\r") { + return null; + } + } + return null; + } + + let depth = 0; + for (let index = destinationStart; index < text.length; index += 1) { + const character = text[index]; + if (isMarkdownEscape(text, index)) { + index += 1; + continue; + } + if (character === "(" && ++depth > MAX_MARKDOWN_DESTINATION_DEPTH) return null; + if (character === ")") { + if (depth === 0) { + return { + href: decodeString(text.slice(destinationStart, index)), + end: index, + }; + } + depth -= 1; + continue; + } + if (character === "\n" || character === "\r" || character === "\t" || character === " ") { + if (depth > 0) return null; + return finishMarkdownDestination(text, text.slice(destinationStart, index), index); + } + } + return null; +} + export function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); - if (href) hrefs.push(href); + const labelOpeners: { readonly image: boolean }[] = []; + let imageOpener = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (isMarkdownEscape(text, index)) { + index += 1; + continue; + } + if (character === "!" && text[index + 1] === "[") { + imageOpener = true; + continue; + } + if (character === "[") { + labelOpeners.push({ image: imageOpener }); + imageOpener = false; + continue; + } + imageOpener = false; + + if (character !== "]") continue; + const opener = labelOpeners.pop(); + if (!opener || text[index + 1] !== "(") continue; + + const destination = parseMarkdownDestination(text, index + 2); + if (!destination) continue; + if (!opener.image) { + hrefs.push(destination.href); + labelOpeners.length = 0; + } + index = destination.end; } + return hrefs; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c79aea36a0e..11d963dfdd7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -597,6 +597,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.6) + micromark-util-decode-string: + specifier: ^2.0.1 + version: 2.0.1 react: specifier: 19.2.6 version: 19.2.6 From bae462869263d15ef73003b6e583554bc2b47359 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:13:08 +0000 Subject: [PATCH 9/9] fix(web): classify rendered markdown file links Co-authored-by: Matthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> --- apps/web/src/components/ChatMarkdown.tsx | 16 ++++-- .../src/markdown-file-link-rendering.test.tsx | 56 +++++++++++++++++++ apps/web/src/markdown-file-link-rendering.ts | 41 ++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/markdown-file-link-rendering.test.tsx create mode 100644 apps/web/src/markdown-file-link-rendering.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6e99012b75fd..d571d9a8e16e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -86,6 +86,10 @@ import { rewriteMarkdownFileUriHref, type MarkdownFileLinkMeta, } from "../markdown-links"; +import { + remarkTagMarkdownLinks, + resolveRenderedMarkdownFileLinkMeta, +} from "../markdown-file-link-rendering"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; @@ -159,6 +163,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), + a: [...(defaultSchema.attributes?.a ?? []), "dataMarkdownLink"], code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, @@ -174,6 +179,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkTagInlineCode, + remarkTagMarkdownLinks, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -183,6 +189,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkBreaks, remarkPreserveCodeMeta, remarkTagInlineCode, + remarkTagMarkdownLinks, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -1397,10 +1404,7 @@ function ChatMarkdown({ }); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { - const metaByHref = new Map< - string, - NonNullable> - >(); + const metaByHref = new Map(); for (const href of extractMarkdownLinkHrefs(text)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; @@ -1614,7 +1618,9 @@ function ChatMarkdown({ }, a({ node, href, children, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + const fileLinkMeta = normalizedHref + ? resolveRenderedMarkdownFileLinkMeta(node, normalizedHref, cwd) + : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; diff --git a/apps/web/src/markdown-file-link-rendering.test.tsx b/apps/web/src/markdown-file-link-rendering.test.tsx new file mode 100644 index 000000000000..ed52d94232bf --- /dev/null +++ b/apps/web/src/markdown-file-link-rendering.test.tsx @@ -0,0 +1,56 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import { describe, expect, it } from "vite-plus/test"; + +import { + remarkTagMarkdownLinks, + resolveRenderedMarkdownFileLinkMeta, +} from "./markdown-file-link-rendering"; + +const sanitizeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + a: [...(defaultSchema.attributes?.a ?? []), "dataMarkdownLink"], + }, +} satisfies Parameters[0]; + +function renderFileLinks(markdown: string): string { + return renderToStaticMarkup( + {children} + ) : ( + {children} + ); + }, + }} + > + {markdown} + , + ); +} + +describe("rendered markdown file links", () => { + it("uses the renderer link when its label contains link-looking inline code", () => { + const markup = renderFileLinks("[see `[x](fake.ts)`](src/real.ts)"); + + expect(markup).toContain('data-file-path="/repo/project/src/real.ts"'); + expect(markup).toContain("[x](fake.ts)"); + expect(markup).not.toContain('data-file-path="/repo/project/fake.ts"'); + }); + + it("does not turn raw html anchors into file links", () => { + const markup = renderFileLinks('raw'); + + expect(markup).toContain('raw'); + expect(markup).not.toContain("data-file-path"); + }); +}); diff --git a/apps/web/src/markdown-file-link-rendering.ts b/apps/web/src/markdown-file-link-rendering.ts new file mode 100644 index 000000000000..4815f4be1525 --- /dev/null +++ b/apps/web/src/markdown-file-link-rendering.ts @@ -0,0 +1,41 @@ +import { resolveMarkdownFileLinkMeta, type MarkdownFileLinkMeta } from "./markdown-links"; + +type MarkdownLinkAstNode = { + type?: string; + data?: { + hProperties?: Record; + }; + children?: MarkdownLinkAstNode[]; +}; + +type RenderedMarkdownLinkNode = { + properties?: Record; +}; + +export function remarkTagMarkdownLinks() { + return (tree: MarkdownLinkAstNode) => { + const visit = (node: MarkdownLinkAstNode) => { + if (node.type === "link" || node.type === "linkReference") { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataMarkdownLink: "", + }, + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + }; +} + +export function resolveRenderedMarkdownFileLinkMeta( + node: RenderedMarkdownLinkNode | undefined, + href: string | undefined, + cwd?: string, +): MarkdownFileLinkMeta | null { + if (node?.properties?.dataMarkdownLink == null) return null; + return resolveMarkdownFileLinkMeta(href, cwd); +}