Unified · Split
| @@ -106,12 +106,12 @@ it.effect("reveals files with the linux file manager", () => · 105 unmodified lines | |||
| 106 | let spawned: ChildProcess.StandardCommand | undefined; | 106 | let spawned: ChildProcess.StandardCommand | undefined; |
| 107 | yield* Effect.gen(function* () { | 107 | yield* Effect.gen(function* () { |
| 108 | const launcher = yield* ExternalLauncher.ExternalLauncher; | 108 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 109 | yield* launcher.revealInFileManager({ path: "/tmp/project/src/index.ts" }); | 109 | yield* launcher.revealInFileManager({ path: "/tmp/project with spaces/src/index.ts" }); |
| 110 | }).pipe( | 110 | }).pipe( |
| 111 | Effect.provide( | 111 | Effect.provide( |
| 112 | testLayer({ | 112 | testLayer({ |
| 113 | platform: "linux", | 113 | platform: "linux", |
| 114 | env: { PATH: binDir }, | 114 | env: { PATH: binDir, DISPLAY: ":0" }, |
| 115 | onSpawn: (command) => { | 115 | onSpawn: (command) => { |
| 116 | spawned = command; | 116 | spawned = command; |
| 117 | }, | 117 | }, |
| @@ -121,11 +121,11 @@ it.effect("reveals files with the linux file manager", () => · 3 unmodified lines | |||
| 121 | 121 | ||
| 122 | assert.ok(spawned); | 122 | assert.ok(spawned); |
| 123 | assert.equal(spawned.command, "xdg-open"); | 123 | assert.equal(spawned.command, "xdg-open"); |
| 124 | assert.deepEqual(spawned.args, ["/tmp/project/src"]); | 124 | assert.deepEqual(spawned.args, ["/tmp/project with spaces/src"]); |
| 125 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 125 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 126 | ); | 126 | ); |
| 127 | 127 | ||
| 128 | it.effect("reveals files with Finder", () => | 128 | it.effect("opens the containing folder with Finder", () => |
| 129 | Effect.gen(function* () { | 129 | Effect.gen(function* () { |
| 130 | const fileSystem = yield* FileSystem.FileSystem; | 130 | const fileSystem = yield* FileSystem.FileSystem; |
| 131 | const path = yield* Path.Path; | 131 | const path = yield* Path.Path; |
| @@ -137,7 +137,7 @@ it.effect("reveals files with Finder", () => · 5 unmodified lines | |||
| 137 | let spawned: ChildProcess.StandardCommand | undefined; | 137 | let spawned: ChildProcess.StandardCommand | undefined; |
| 138 | yield* Effect.gen(function* () { | 138 | yield* Effect.gen(function* () { |
| 139 | const launcher = yield* ExternalLauncher.ExternalLauncher; | 139 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 140 | yield* launcher.revealInFileManager({ path: "/tmp/project/src/index.ts" }); | 140 | yield* launcher.revealInFileManager({ path: "/tmp/project with spaces/src/index.ts" }); |
| 141 | }).pipe( | 141 | }).pipe( |
| 142 | Effect.provide( | 142 | Effect.provide( |
| 143 | testLayer({ | 143 | testLayer({ |
| @@ -152,11 +152,11 @@ it.effect("reveals files with Finder", () => · 8 unmodified lines | |||
| 152 | 152 | ||
| 153 | assert.ok(spawned); | 153 | assert.ok(spawned); |
| 154 | assert.equal(spawned.command, "open"); | 154 | assert.equal(spawned.command, "open"); |
| 155 | assert.deepEqual(spawned.args, ["-R", "/tmp/project/src/index.ts"]); | 155 | assert.deepEqual(spawned.args, ["/tmp/project with spaces/src"]); |
| 156 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 156 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 157 | ); | 157 | ); |
| 158 | 158 | ||
| 159 | it.effect("reveals files with Windows Explorer", () => | 159 | it.effect("opens the containing folder with Windows Explorer", () => |
| 160 | Effect.gen(function* () { | 160 | Effect.gen(function* () { |
| 161 | const fileSystem = yield* FileSystem.FileSystem; | 161 | const fileSystem = yield* FileSystem.FileSystem; |
| 162 | const path = yield* Path.Path; | 162 | const path = yield* Path.Path; |
| @@ -181,7 +181,8 @@ it.effect("reveals files with Windows Explorer", () => · 18 unmodified lines | |||
| 181 | 181 | ||
| 182 | assert.ok(spawned); | 182 | assert.ok(spawned); |
| 183 | assert.equal(spawned.command, "explorer"); | 183 | assert.equal(spawned.command, "explorer"); |
| 184 | assert.deepEqual(spawned.args, [`/select,"C:\\project files\\src\\index.ts"`]); | 184 | assert.deepEqual(spawned.args, [`C:\\project files\\src`]); |
| 185 | assert.equal(spawned.options.shell, false); | ||
| 185 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 186 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 186 | ); | 187 | ); |
| 187 | 188 | ||
| @@ -371,6 +372,107 @@ it.effect("rescans after an interrupted discovery instead of caching the interru · 183 unmodified lines | |||
| 371 | ), | 372 | ), |
| 372 | ); | 373 | ); |
| 373 | }); | 374 | }); |
| 375 | it.effect("does not advertise a file manager on headless Linux", () => | ||
| 376 | Effect.gen(function* () { | ||
| 377 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 378 | const path = yield* Path.Path; | ||
| 379 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-headless-linux-" }); | ||
| 380 | const commandPath = path.join(binDir, "xdg-open"); | ||
| 381 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | ||
| 382 | yield* fileSystem.chmod(commandPath, 0o755); | ||
| 383 | |||
| 384 | const editors = yield* Effect.gen(function* () { | ||
| 385 | const launcher = yield* ExternalLauncher.ExternalLauncher; | ||
| 386 | return yield* launcher.resolveAvailableEditors(); | ||
| 387 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); | ||
| 388 | |||
| 389 | assert.equal(editors.includes("file-manager"), false); | ||
| 390 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 391 | ); | ||
| 392 | |||
| 393 | it.effect("advertises a file manager in a Linux graphical session", () => | ||
| 394 | Effect.gen(function* () { | ||
| 395 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 396 | const path = yield* Path.Path; | ||
| 397 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-linux-" }); | ||
| 398 | const commandPath = path.join(binDir, "xdg-open"); | ||
| 399 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | ||
| 400 | yield* fileSystem.chmod(commandPath, 0o755); | ||
| 401 | |||
| 402 | const editors = yield* Effect.gen(function* () { | ||
| 403 | const launcher = yield* ExternalLauncher.ExternalLauncher; | ||
| 404 | return yield* launcher.resolveAvailableEditors(); | ||
| 405 | }).pipe( | ||
| 406 | Effect.provide( | ||
| 407 | testLayer({ platform: "linux", env: { PATH: binDir, WAYLAND_DISPLAY: "wayland-0" } }), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | |||
| 411 | assert.equal(editors.includes("file-manager"), true); | ||
| 412 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 413 | ); | ||
| 414 | |||
| 415 | it.effect("rejects a direct file manager reveal on headless Linux", () => | ||
| 416 | Effect.gen(function* () { | ||
| 417 | const launcher = yield* ExternalLauncher.ExternalLauncher; | ||
| 418 | const error = yield* launcher | ||
| 419 | .revealInFileManager({ path: "/tmp/project/src/index.ts" }) | ||
| 420 | .pipe(Effect.flip); | ||
| 421 | assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnsupportedEditorError); | ||
| 422 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), | ||
| 423 | ); | ||
| 424 | |||
| 425 | it.effect("does not advertise a file manager over SSH", () => | ||
| 426 | Effect.gen(function* () { | ||
| 427 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 428 | const path = yield* Path.Path; | ||
| 429 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ssh-macos-" }); | ||
| 430 | const commandPath = path.join(binDir, "open"); | ||
| 431 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | ||
| 432 | yield* fileSystem.chmod(commandPath, 0o755); | ||
| 433 | |||
| 434 | const editors = yield* Effect.gen(function* () { | ||
| 435 | const launcher = yield* ExternalLauncher.ExternalLauncher; | ||
| 436 | return yield* launcher.resolveAvailableEditors(); | ||
| 437 | }).pipe( | ||
| 438 | Effect.provide( | ||
| 439 | testLayer({ | ||
| 440 | platform: "darwin", | ||
| 441 | env: { PATH: binDir, SSH_CONNECTION: "client server" }, | ||
| 442 | }), | ||
| 443 | ), | ||
| 444 | ); | ||
| 445 | |||
| 446 | assert.equal(editors.includes("file-manager"), false); | ||
| 447 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 448 | ); | ||
| 449 | |||
| 450 | it.effect("does not advertise Explorer from a Windows service", () => | ||
| 451 | Effect.gen(function* () { | ||
| 452 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 453 | const path = yield* Path.Path; | ||
| 454 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-windows-service-" }); | ||
| 455 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); | ||
| 456 | |||
| 457 | const editors = yield* Effect.gen(function* () { | ||
| 458 | const launcher = yield* ExternalLauncher.ExternalLauncher; | ||
| 459 | return yield* launcher.resolveAvailableEditors(); | ||
| 460 | }).pipe( | ||
| 461 | Effect.provide( | ||
| 462 | testLayer({ | ||
| 463 | platform: "win32", | ||
| 464 | env: { | ||
| 465 | PATH: binDir, | ||
| 466 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | ||
| 467 | SESSIONNAME: "Services", | ||
| 468 | }, | ||
| 469 | }), | ||
| 470 | ), | ||
| 471 | ); | ||
| 472 | |||
| 473 | assert.equal(editors.includes("file-manager"), false); | ||
| 474 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 475 | ); | ||
| 374 | 476 | ||
| 375 | it.effect("rejects unknown editors through the service API", () => | 477 | it.effect("rejects unknown editors through the service API", () => |
| 376 | Effect.gen(function* () { | 478 | Effect.gen(function* () { |
| @@ -107,6 +107,11 @@ const CommandLookupEnvConfig = Config.all({ · 106 unmodified lines | |||
| 107 | Path: Config.string("Path").pipe(Config.option), | 107 | Path: Config.string("Path").pipe(Config.option), |
| 108 | path: Config.string("path").pipe(Config.option), | 108 | path: Config.string("path").pipe(Config.option), |
| 109 | PATHEXT: Config.string("PATHEXT").pipe(Config.option), | 109 | PATHEXT: Config.string("PATHEXT").pipe(Config.option), |
| 110 | DISPLAY: Config.string("DISPLAY").pipe(Config.option), | ||
| 111 | WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), | ||
| 112 | SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), | ||
| 113 | SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), | ||
| 114 | SESSIONNAME: Config.string("SESSIONNAME").pipe(Config.option), | ||
| 110 | }).pipe(Config.map(compactEnv)); | 115 | }).pipe(Config.map(compactEnv)); |
| 111 | 116 | ||
| 112 | const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); | 117 | const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); |
| @@ -224,6 +229,30 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa · 111 unmodified lines | |||
| 224 | }; | 229 | }; |
| 225 | } | 230 | } |
| 226 | 231 | ||
| 232 | function hasGraphicalFileManagerSession( | ||
| 233 | platform: NodeJS.Platform, | ||
| 234 | env: NodeJS.ProcessEnv, | ||
| 235 | ): boolean { | ||
| 236 | if (env.SSH_CONNECTION?.trim() || env.SSH_TTY?.trim()) return false; | ||
| 237 | if (platform === "linux") { | ||
| 238 | return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim()); | ||
| 239 | } | ||
| 240 | if (platform === "win32") { | ||
| 241 | return env.SESSIONNAME?.trim().toLowerCase() !== "services"; | ||
| 242 | } | ||
| 243 | return true; | ||
| 244 | } | ||
| 245 | |||
| 246 | function fileManagerFolderPath(platform: NodeJS.Platform, target: string, path: Path.Path): string { | ||
| 247 | if (platform !== "win32") return path.dirname(target); | ||
| 248 | |||
| 249 | const normalized = target.replaceAll("/", "\\"); | ||
| 250 | const separatorIndex = normalized.lastIndexOf("\\"); | ||
| 251 | if (separatorIndex < 0) return "."; | ||
| 252 | if (separatorIndex === 2 && normalized[1] === ":") return normalized.slice(0, 3); | ||
| 253 | return normalized.slice(0, separatorIndex) || "\\"; | ||
| 254 | } | ||
| 255 | |||
| 227 | function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { | 256 | function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { |
| 228 | switch (platform) { | 257 | switch (platform) { |
| 229 | case "darwin": | 258 | case "darwin": |
| @@ -271,6 +300,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" · 41 unmodified lines | |||
| 271 | 300 | ||
| 272 | for (const editor of EDITORS) { | 301 | for (const editor of EDITORS) { |
| 273 | if (editor.commands === null) { | 302 | if (editor.commands === null) { |
| 303 | if (!hasGraphicalFileManagerSession(platform, env)) continue; | ||
| 274 | const command = fileManagerCommandForPlatform(platform); | 304 | const command = fileManagerCommandForPlatform(platform); |
| 275 | if (yield* isCommandAvailable(command, { env })) { | 305 | if (yield* isCommandAvailable(command, { env })) { |
| 276 | available.push(editor.id); | 306 | available.push(editor.id); |
| @@ -390,15 +420,16 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( · 113 unmodified lines | |||
| 390 | }); | 420 | }); |
| 391 | 421 | ||
| 392 | const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileManagerRevealLaunch")( | 422 | const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileManagerRevealLaunch")( |
| 393 | function* (input: RevealInFileManagerInput): Effect.fn.Return<EditorLaunch, never, Path.Path> { | 423 | function* ( |
| 424 | input: RevealInFileManagerInput, | ||
| 425 | ): Effect.fn.Return<EditorLaunch, ExternalLauncherError, Path.Path> { | ||
| 394 | const platform = yield* HostProcessPlatform; | 426 | const platform = yield* HostProcessPlatform; |
| 427 | const env = yield* readCommandLookupEnv; | ||
| 428 | if (!hasGraphicalFileManagerSession(platform, env)) { | ||
| 429 | return yield* new ExternalLauncherUnsupportedEditorError({ editor: "file-manager" }); | ||
| 430 | } | ||
| 395 | const path = yield* Path.Path; | 431 | const path = yield* Path.Path; |
| 396 | const args = | 432 | const args = [fileManagerFolderPath(platform, input.path, path)]; |
| 397 | platform === "darwin" | ||
| 398 | ? ["-R", input.path] | ||
| 399 | : platform === "win32" | ||
| 400 | ? [`/select,"${input.path}"`] | ||
| 401 | : [path.dirname(input.path)]; | ||
| 402 | 433 | ||
| 403 | yield* Effect.annotateCurrentSpan({ | 434 | yield* Effect.annotateCurrentSpan({ |
| 404 | "externalLauncher.target": input.path, | 435 | "externalLauncher.target": input.path, |
| @@ -51,7 +51,11 @@ import { · 50 unmodified lines | |||
| 51 | resolveExternalWebLinkHost, | 51 | resolveExternalWebLinkHost, |
| 52 | showExternalLinkContextMenu, | 52 | showExternalLinkContextMenu, |
| 53 | } from "./chat/externalLinkContextMenu"; | 53 | } from "./chat/externalLinkContextMenu"; |
| 54 | import { buildFileLinkContextMenuItems } from "./chat/fileLinkContextMenu"; | 54 | import { |
| 55 | buildFileLinkContextMenuItems, | ||
| 56 | canRevealFileLinkInManager, | ||
| 57 | resolveFileLinkEnvironmentId, | ||
| 58 | } from "./chat/fileLinkContextMenu"; | ||
| 55 | import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; | 59 | import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; |
| 56 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 60 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 57 | import { Button } from "./ui/button"; | 61 | import { Button } from "./ui/button"; |
| @@ -85,6 +89,7 @@ import { readLocalApi } from "../localApi"; · 27 unmodified lines | |||
| 85 | import { cn } from "../lib/utils"; | 89 | import { cn } from "../lib/utils"; |
| 86 | import { useRightPanelStore } from "../rightPanelStore"; | 90 | import { useRightPanelStore } from "../rightPanelStore"; |
| 87 | import { useActiveEnvironmentId } from "../state/entities"; | 91 | import { useActiveEnvironmentId } from "../state/entities"; |
| 92 | import { useEnvironment } from "../state/environments"; | ||
| 88 | import { serverEnvironment } from "../state/server"; | 93 | import { serverEnvironment } from "../state/server"; |
| 89 | import { assetEnvironment } from "../state/assets"; | 94 | import { assetEnvironment } from "../state/assets"; |
| 90 | import { usePreparedConnection } from "../state/session"; | 95 | import { usePreparedConnection } from "../state/session"; |
| @@ -1394,15 +1399,17 @@ function ChatMarkdown({ · 1303 unmodified lines | |||
| 1394 | reportFailure: false, | 1399 | reportFailure: false, |
| 1395 | }); | 1400 | }); |
| 1396 | const activeEnvironmentId = useActiveEnvironmentId(); | 1401 | const activeEnvironmentId = useActiveEnvironmentId(); |
| 1397 | const environmentId = threadRef?.environmentId ?? activeEnvironmentId; | 1402 | const environmentId = resolveFileLinkEnvironmentId(threadRef?.environmentId, activeEnvironmentId); |
| 1403 | const environment = useEnvironment(environmentId); | ||
| 1398 | const preparedConnection = usePreparedConnection(environmentId); | 1404 | const preparedConnection = usePreparedConnection(environmentId); |
| 1399 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | 1405 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 1400 | const availableEditors = serverConfig?.availableEditors ?? []; | 1406 | const availableEditors = serverConfig?.availableEditors ?? []; |
| 1401 | const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); | 1407 | const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); |
| 1402 | const canRevealInFileManager = | 1408 | const canRevealInFileManager = canRevealFileLinkInManager({ |
| 1403 | preparedConnection._tag === "Some" && | 1409 | connectionPhase: environment?.connection.phase, |
| 1404 | serverConfig?.environment.capabilities.fileManagerReveal === true && | 1410 | supportsRevealRpc: serverConfig?.environment.capabilities.fileManagerReveal === true, |
| 1405 | availableEditors.includes("file-manager"); | 1411 | availableEditors, |
| 1412 | }); | ||
| 1406 | const diffThemeName = resolveDiffThemeName(resolvedTheme); | 1413 | const diffThemeName = resolveDiffThemeName(resolvedTheme); |
| 1407 | const markdownFileLinkMetaByHref = useMemo(() => { | 1414 | const markdownFileLinkMetaByHref = useMemo(() => { |
| 1408 | const metaByHref = new Map< | 1415 | const metaByHref = new Map< |
| @@ -1,6 +1,47 @@ | |||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { buildFileLinkContextMenuItems } from "./fileLinkContextMenu"; | 4 | import { |
| 5 | buildFileLinkContextMenuItems, | ||
| 6 | canRevealFileLinkInManager, | ||
| 7 | resolveFileLinkEnvironmentId, | ||
| 8 | } from "./fileLinkContextMenu"; | ||
| 9 | |||
| 10 | describe("file link environment", () => { | ||
| 11 | it("routes through the thread environment before the active environment", () => { | ||
| 12 | const threadEnvironmentId = EnvironmentId.make("thread-environment"); | ||
| 13 | expect( | ||
| 14 | resolveFileLinkEnvironmentId(threadEnvironmentId, EnvironmentId.make("active-environment")), | ||
| 15 | ).toBe(threadEnvironmentId); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("falls back to the active environment without thread context", () => { | ||
| 19 | const activeEnvironmentId = EnvironmentId.make("active-environment"); | ||
| 20 | expect(resolveFileLinkEnvironmentId(undefined, activeEnvironmentId)).toBe(activeEnvironmentId); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it.each([ | ||
| 24 | { | ||
| 25 | connectionPhase: "reconnecting", | ||
| 26 | supportsRevealRpc: true, | ||
| 27 | availableEditors: ["file-manager"], | ||
| 28 | }, | ||
| 29 | { connectionPhase: "connected", supportsRevealRpc: false, availableEditors: ["file-manager"] }, | ||
| 30 | { connectionPhase: "connected", supportsRevealRpc: true, availableEditors: [] }, | ||
| 31 | ] as const)("hides reveal when support is incomplete", (input) => { | ||
| 32 | expect(canRevealFileLinkInManager(input)).toBe(false); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("allows reveal only when connected with rpc and file manager support", () => { | ||
| 36 | expect( | ||
| 37 | canRevealFileLinkInManager({ | ||
| 38 | connectionPhase: "connected", | ||
| 39 | supportsRevealRpc: true, | ||
| 40 | availableEditors: ["file-manager"], | ||
| 41 | }), | ||
| 42 | ).toBe(true); | ||
| 43 | }); | ||
| 44 | }); | ||
| 4 | 45 | ||
| 5 | describe("chat file link context menu", () => { | 46 | describe("chat file link context menu", () => { |
| 6 | it("puts Open in folder first when the environment supports it", () => { | 47 | it("puts Open in folder first when the environment supports it", () => { |
| @@ -1,4 +1,4 @@ | |||
| 1 | import type { ContextMenuItem } from "@t3tools/contracts"; | 1 | import type { ContextMenuItem, EditorId, EnvironmentId } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export type FileLinkContextMenuAction = | 3 | export type FileLinkContextMenuAction = |
| 4 | | "open-in-folder" | 4 | | "open-in-folder" |
| @@ -7,6 +7,25 @@ export type FileLinkContextMenuAction = · 2 unmodified lines | |||
| 7 | | "copy-relative" | 7 | | "copy-relative" |
| 8 | | "copy-full"; | 8 | | "copy-full"; |
| 9 | 9 | ||
| 10 | export function resolveFileLinkEnvironmentId( | ||
| 11 | threadEnvironmentId: EnvironmentId | undefined, | ||
| 12 | activeEnvironmentId: EnvironmentId | null, | ||
| 13 | ): EnvironmentId | null { | ||
| 14 | return threadEnvironmentId ?? activeEnvironmentId; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function canRevealFileLinkInManager(input: { | ||
| 18 | readonly connectionPhase: string | undefined; | ||
| 19 | readonly supportsRevealRpc: boolean; | ||
| 20 | readonly availableEditors: ReadonlyArray<EditorId>; | ||
| 21 | }): boolean { | ||
| 22 | return ( | ||
| 23 | input.connectionPhase === "connected" && | ||
| 24 | input.supportsRevealRpc && | ||
| 25 | input.availableEditors.includes("file-manager") | ||
| 26 | ); | ||
| 27 | } | ||
| 28 | |||
| 10 | export function buildFileLinkContextMenuItems(input: { | 29 | export function buildFileLinkContextMenuItems(input: { |
| 11 | readonly canRevealInFileManager: boolean; | 30 | readonly canRevealInFileManager: boolean; |
| 12 | readonly canOpenInBrowser: boolean; | 31 | readonly canOpenInBrowser: boolean; |
| @@ -108,6 +108,27 @@ describe("resolveMarkdownFileLinkTarget", () => { · 107 unmodified lines | |||
| 108 | }); | 108 | }); |
| 109 | }); | 109 | }); |
| 110 | 110 | ||
| 111 | it("resolves the exact file path used by open in folder", () => { | ||
| 112 | expect(resolveMarkdownFileLinkMeta("src/file%20name.ts:12:4", "/repo/project")).toMatchObject({ | ||
| 113 | filePath: "/repo/project/src/file name.ts", | ||
| 114 | targetPath: "/repo/project/src/file name.ts:12:4", | ||
| 115 | line: 12, | ||
| 116 | column: 4, | ||
| 117 | }); | ||
| 118 | expect(resolveMarkdownFileLinkMeta("/tmp/report.ts:9", "/repo/project")).toMatchObject({ | ||
| 119 | filePath: "/tmp/report.ts", | ||
| 120 | targetPath: "/tmp/report.ts:9", | ||
| 121 | line: 9, | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("resolves missing paths without requiring browser-side file access", () => { | ||
| 126 | expect(resolveMarkdownFileLinkMeta("src/not-created-yet.ts", "/repo/project")).toMatchObject({ | ||
| 127 | filePath: "/repo/project/src/not-created-yet.ts", | ||
| 128 | workspaceRelativePath: "src/not-created-yet.ts", | ||
| 129 | }); | ||
| 130 | }); | ||
| 131 | |||
| 111 | it("normalizes slash-prefixed windows drive paths before resolving", () => { | 132 | it("normalizes slash-prefixed windows drive paths before resolving", () => { |
| 112 | expect( | 133 | expect( |
| 113 | resolveMarkdownFileLinkTarget( | 134 | resolveMarkdownFileLinkTarget( |
| @@ -5,8 +5,8 @@ const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; · 4 unmodified lines | |||
| 5 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; | 5 | const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; |
| 6 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; | 6 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; |
| 7 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | 7 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; |
| 8 | const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; | 8 | const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._ -]+(?:\/[A-Za-z0-9._ -]+)+(?::\d+){0,2}$/; |
| 9 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | 9 | const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._ -]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; |
| 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 11 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | 11 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; |
| 12 | // Standard OS and dev-container roots; deliberately excludes app-route-ish | 12 | // Standard OS and dev-container roots; deliberately excludes app-route-ish |