diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2aa057ee0ca..86d2ec353d5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -60,6 +60,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/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a7aea90f826..f50687cac4d 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -70,6 +70,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.fileManagerReveal).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c697b4bd98f..0c55995930b 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -148,6 +148,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/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1ab6166e92a..7fd2510ef1f 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -94,6 +94,165 @@ 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 with spaces/src/index.ts" }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "xdg-open"); + assert.deepEqual(spawned.args, ["/tmp/project with spaces/src"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +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; + 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 with spaces/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, ["/tmp/project with spaces/src"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +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; + 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 files\\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, [`C:\\project files\\src`]); + assert.equal(spawned.options.shell, false); + }).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; @@ -280,6 +439,149 @@ 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("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; + 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 8ec928f26fc..2e6adb8ec7f 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; @@ -66,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", @@ -106,6 +108,14 @@ 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), + 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(() => ({}))); @@ -223,7 +233,78 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { +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()); + } + if (platform === "win32") { + return env.SESSIONNAME?.trim().toLowerCase() !== "services"; + } + return true; +} + +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); + + 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, + 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, env: NodeJS.ProcessEnv): string { + if (shouldUseWindowsFileManagerFromWsl(platform, env)) return "explorer.exe"; switch (platform) { case "darwin": return "open"; @@ -270,7 +351,8 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" for (const editor of EDITORS) { if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); + if (!hasGraphicalFileManagerSession(platform, env)) continue; + const command = fileManagerCommandForPlatform(platform, env); if (yield* isCommandAvailable(command, { env })) { available.push(editor.id); } @@ -337,6 +419,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") {} @@ -376,14 +462,53 @@ 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, + ], }; }); +const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileManagerRevealLaunch")( + 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 fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.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": target, + "externalLauncher.platform": platform, + }); + + return { + editor: "file-manager", + target, + command: fileManagerCommandForPlatform(platform, env), + args, + }; + }, +); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -501,6 +626,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 4ddb01e09dd..c8294e56d54 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5089,6 +5089,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 a6b155c296f..c66ad55dafc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1708,6 +1708,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/package.json b/apps/web/package.json index f396bff7a5e..1eb5d275cea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "jose": "catalog:", "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/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b5d33facc96..584000b91d1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -45,6 +45,11 @@ import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; +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"; @@ -68,7 +73,8 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { - normalizeMarkdownLinkDestination, + extractMarkdownLinkHrefs, + normalizeMarkdownLinkHrefKey, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -78,10 +84,12 @@ 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"; 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 { writeTextToClipboard } from "../hooks/useCopyToClipboard"; @@ -734,6 +742,7 @@ function UncachedShikiCodeBlock({ interface MarkdownFileLinkProps { href: string; + filePath: string; targetPath: string; iconPath: string; displayPath: string; @@ -744,11 +753,12 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; + canRevealInFileManager: boolean; + onRevealInFileManager: (filePath: string) => Promise>; onOpenInBrowser?: (() => Promise>) | undefined; 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"; @@ -827,21 +837,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. */ @@ -1017,6 +1012,7 @@ function MarkdownExternalLinkContent({ const MarkdownFileLink = memo(function MarkdownFileLink({ href, + filePath, targetPath, iconPath, displayPath, @@ -1027,6 +1023,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + canRevealInFileManager, + onRevealInFileManager, onOpenInBrowser, className, }: MarkdownFileLinkProps) { @@ -1065,6 +1063,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(); @@ -1160,14 +1190,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ try { const clicked = await api.contextMenu.show( - [ - { 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 }, ); @@ -1175,6 +1201,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor(); return; } + if (clicked === "open-in-folder") { + handleRevealInFileManager(); + return; + } if (clicked === "open-in-browser") { handleOpenInBrowser(); return; @@ -1193,7 +1223,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + canRevealInFileManager, + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + targetPath, + ], ); return ( @@ -1237,6 +1276,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 && @@ -1247,6 +1287,8 @@ 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 ); @@ -1269,13 +1311,21 @@ function ChatMarkdown({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); + const revealInFileManager = useAtomCommand(shellEnvironment.revealInFileManager, { + reportFailure: false, + }); + const activeEnvironmentId = useActiveEnvironmentId(); + const environmentId = resolveFileLinkEnvironmentId(threadRef?.environmentId, activeEnvironmentId); + const environment = useEnvironment(environmentId); + 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 = canRevealFileLinkInManager({ + connectionPhase: environment?.connection.phase, + supportsRevealRpc: serverConfig?.environment.capabilities.fileManagerReveal === true, + availableEditors, + }); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -1344,6 +1394,20 @@ function ChatMarkdown({ }, [openPreview, threadRef], ); + const revealMarkdownFileInFileManager = useCallback( + (filePath: string): Promise> => { + if (environmentId === null) { + return Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("No environment is selected."))), + ); + } + return revealInFileManager({ + environmentId, + input: { path: filePath }, + }); + }, + [environmentId, revealInFileManager], + ); const openMarkdownFileInPreview = useCallback( (path: string) => { if (!threadRef || preparedConnection._tag === "None") { @@ -1390,6 +1454,7 @@ function ChatMarkdown({ return ( { + 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", () => { + 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 00000000000..01c7869b884 --- /dev/null +++ b/apps/web/src/components/chat/fileLinkContextMenu.ts @@ -0,0 +1,44 @@ +import type { ContextMenuItem, EditorId, EnvironmentId } from "@t3tools/contracts"; + +export type FileLinkContextMenuAction = + | "open-in-folder" + | "open" + | "open-in-browser" + | "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; +}): 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/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc29613867..fb9648d9523 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,12 +1,79 @@ 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("src/file name.ts"); + 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"]); + }); + + 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]( { it("rewrites file uri hrefs into direct path hrefs", () => { expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( @@ -108,6 +175,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 a6dba941b8a..8ee071ae5fd 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,10 +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_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 = [ @@ -64,6 +69,153 @@ 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[] = []; + 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; +} + function stripSearchAndHash(value: string): { path: string; hash: string } { const hashIndex = value.indexOf("#"); const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; @@ -108,6 +260,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; diff --git a/packages/client-runtime/src/state/shellCommands.ts b/packages/client-runtime/src/state/shellCommands.ts index 785bb83ed47..2ea04b82d77 100644 --- a/packages/client-runtime/src/state/shellCommands.ts +++ b/packages/client-runtime/src/state/shellCommands.ts @@ -12,5 +12,9 @@ export function createShellEnvironmentAtoms( 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 5948d87e1d2..20b5152310c 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -50,6 +50,11 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.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/environment.ts b/packages/contracts/src/environment.ts index 329ff911503..ca7c5e5c433 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -56,6 +56,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). */ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index db40b10fed9..95204360dba 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,7 +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 } from "./editor.ts"; +import { ExternalLauncherError, LaunchEditorInput, RevealInFileManagerInput } from "./editor.ts"; import { AuthAccessStreamError, AuthAccessStreamEvent, @@ -178,6 +178,7 @@ export const WS_METHODS = { // Shell methods shellOpenInEditor: "shell.openInEditor", + shellRevealInFileManager: "shell.revealInFileManager", // Filesystem methods filesystemBrowse: "filesystem.browse", @@ -475,6 +476,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, @@ -834,6 +840,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, + WsShellRevealInFileManagerRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b999993183..2ade8c014ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -578,6 +578,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