| author | |
| committer | |
| log | 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 |
| tree | d12206848dd49c269b8f1d73effcfc661ca86c55 |
| parent | e67074f80933a27bd3cdc4e24f486358407690fb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Dara Adedeji <daraaded@amazon.com>
Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>22 files changed, 1658 insertions(+), 141 deletions(-)
.macroscope/check-run-agents/ui-consistency.md+7| ... | ... | @@ -70,6 +70,13 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put |
| 70 | 70 | - Do not treat a screenshot as proof of keyboard, overflow, scrollbar, responsive, or runtime-theme behavior. Pair visual evidence with source, computed-style, emitted-CSS, or interaction checks as appropriate. |
| 71 | 71 | - Be alert to shared primitive color indirection. When a primitive routes icon color through a CSS variable, ensure migrated contextual icons retain their intended tone, including pressed and disabled states. |
| 72 | 72 | |
| 73 | ## Environment routing in shared renderers | |
| 74 | ||
| 75 | - A shared renderer that performs an environment-scoped action — a server RPC such as opening or revealing a file, an environment-gated capability check, or an OS-derived label — must resolve its target environment from explicit scope: the bound thread's `environmentId`, or an `environmentId` prop threaded from the owning surface. Never let it silently fall back to the globally active environment. Multi-environment surfaces (pull request panels, review annotations, cross-environment listings) can render content from environment B while environment A is active; a silent fallback sends B's paths to A's server and presents A's platform wording. | |
| 76 | - When a call site cannot supply an explicit environment scope, suppress the environment-scoped actions at that call site rather than guessing. A hidden menu item is correct; an item that targets the wrong server is a concrete finding. | |
| 77 | - Capability gating, action dispatch, and user-facing labels must all read from the same environment's server config that the action will execute against. Flag a renderer whose label derives from one environment while its RPC targets another. | |
| 78 | - Flag new call sites of shared markdown, chip, or menu renderers that trigger environment actions without passing explicit scope, and flag new environment-action props whose default reintroduces an active-environment fallback. | |
| 79 | ||
| 73 | 80 | ## Change discipline |
| 74 | 81 | |
| 75 | 82 | - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. |
apps/server/src/process/externalLauncher.test.ts+643-5| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import * as NodeFS from "node:fs"; | |
| 4 | import * as NodeOS from "node:os"; | |
| 5 | import * as NodePath from "node:path"; | |
| 1 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 7 | import { assert, it } from "@effect/vitest"; |
| 3 | 8 | import * as ConfigProvider from "effect/ConfigProvider"; |
| ... | ... | @@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 15 | 20 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 16 | 21 | import * as ExternalLauncher from "./externalLauncher.ts"; |
| 17 | 22 | |
| 18 | function makeMockDetachedHandle(onUnref: () => void = () => undefined) { | |
| 23 | interface MockSpawnResult { | |
| 24 | readonly exitCode?: number; | |
| 25 | readonly stdout?: string; | |
| 26 | /** Never deliver an exit code, like a child wedged on a broken desktop session. */ | |
| 27 | readonly stall?: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) { | |
| 19 | 31 | return ChildProcessSpawner.makeHandle({ |
| 20 | 32 | pid: ChildProcessSpawner.ProcessId(1), |
| 21 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), | |
| 33 | exitCode: input.stall | |
| 34 | ? Effect.never | |
| 35 | : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), | |
| 22 | 36 | isRunning: Effect.succeed(true), |
| 23 | 37 | kill: () => Effect.void, |
| 24 | 38 | unref: Effect.sync(() => { |
| 25 | onUnref(); | |
| 39 | input.onUnref?.(); | |
| 26 | 40 | return Effect.void; |
| 27 | 41 | }), |
| 28 | 42 | stdin: Sink.drain, |
| 29 | stdout: Stream.empty, | |
| 43 | stdout: | |
| 44 | input.stdout === undefined | |
| 45 | ? Stream.empty | |
| 46 | : Stream.make(new TextEncoder().encode(input.stdout)), | |
| 30 | 47 | stderr: Stream.empty, |
| 31 | 48 | all: Stream.empty, |
| 32 | 49 | getInputFd: () => Sink.drain, |
| ... | ... | @@ -40,6 +57,7 @@ const testLayer = (input: { |
| 40 | 57 | readonly resolveExecutable?: (command: string) => string | undefined; |
| 41 | 58 | readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; |
| 42 | 59 | readonly onUnref?: () => void; |
| 60 | readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined; | |
| 43 | 61 | }) => { |
| 44 | 62 | const spawnerLayer = Layer.succeed( |
| 45 | 63 | ChildProcessSpawner.ChildProcessSpawner, |
| ... | ... | @@ -50,7 +68,10 @@ const testLayer = (input: { |
| 50 | 68 | throw new Error("Expected a standard command"); |
| 51 | 69 | } |
| 52 | 70 | input.onSpawn?.(command); |
| 53 | return makeMockDetachedHandle(input.onUnref); | |
| 71 | return makeMockDetachedHandle({ | |
| 72 | ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), | |
| 73 | ...input.spawnResult?.(command), | |
| 74 | }); | |
| 54 | 75 | }), |
| 55 | 76 | ), |
| 56 | 77 | ); |
| ... | ... | @@ -132,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () => |
| 132 | 153 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 133 | 154 | ); |
| 134 | 155 | |
| 156 | it.effect("reveals a file in Finder with open -R on macOS", () => | |
| 157 | Effect.gen(function* () { | |
| 158 | const fileSystem = yield* FileSystem.FileSystem; | |
| 159 | const path = yield* Path.Path; | |
| 160 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 161 | const openPath = path.join(binDir, "open"); | |
| 162 | yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); | |
| 163 | yield* fileSystem.chmod(openPath, 0o755); | |
| 164 | ||
| 165 | let spawned: ChildProcess.StandardCommand | undefined; | |
| 166 | yield* Effect.gen(function* () { | |
| 167 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 168 | yield* launcher.launchEditor({ | |
| 169 | editor: "file-manager", | |
| 170 | cwd: "/workspace/media/linux-mini-v2.mp4", | |
| 171 | reveal: true, | |
| 172 | }); | |
| 173 | }).pipe( | |
| 174 | Effect.provide( | |
| 175 | testLayer({ | |
| 176 | platform: "darwin", | |
| 177 | env: { PATH: binDir }, | |
| 178 | onSpawn: (command) => { | |
| 179 | spawned = command; | |
| 180 | }, | |
| 181 | }), | |
| 182 | ), | |
| 183 | ); | |
| 184 | ||
| 185 | assert.ok(spawned); | |
| 186 | assert.equal(spawned.command, "open"); | |
| 187 | assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); | |
| 188 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 189 | ); | |
| 190 | ||
| 191 | it.effect("reveals a file in File Explorer through PowerShell on Windows", () => | |
| 192 | Effect.gen(function* () { | |
| 193 | const fileSystem = yield* FileSystem.FileSystem; | |
| 194 | const path = yield* Path.Path; | |
| 195 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 196 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); | |
| 197 | // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows | |
| 198 | // separators, which on the posix test filesystem is one file name. | |
| 199 | const systemRoot = path.join(binDir, "system-root"); | |
| 200 | const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; | |
| 201 | yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); | |
| 202 | yield* fileSystem.writeFileString(powerShellPath, ""); | |
| 203 | ||
| 204 | let spawned: ChildProcess.StandardCommand | undefined; | |
| 205 | const kind = yield* Effect.gen(function* () { | |
| 206 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 207 | yield* launcher.launchEditor({ | |
| 208 | editor: "file-manager", | |
| 209 | cwd: "C:\\workspace with spaces\\media\\author's clip.mp4", | |
| 210 | reveal: true, | |
| 211 | }); | |
| 212 | return yield* launcher.resolveFileManagerRevealKind(); | |
| 213 | }).pipe( | |
| 214 | Effect.provide( | |
| 215 | testLayer({ | |
| 216 | platform: "win32", | |
| 217 | env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, | |
| 218 | onSpawn: (command) => { | |
| 219 | spawned = command; | |
| 220 | }, | |
| 221 | }), | |
| 222 | ), | |
| 223 | ); | |
| 224 | ||
| 225 | assert.equal(kind, "file-explorer"); | |
| 226 | assert.ok(spawned); | |
| 227 | assert.equal(spawned.command, powerShellPath); | |
| 228 | assert.deepEqual(spawned.args.slice(0, -1), [ | |
| 229 | "-NoProfile", | |
| 230 | "-NonInteractive", | |
| 231 | "-ExecutionPolicy", | |
| 232 | "Bypass", | |
| 233 | "-EncodedCommand", | |
| 234 | ]); | |
| 235 | const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; | |
| 236 | const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); | |
| 237 | // explorer.exe expects `/select,"<path>"` with only the path quoted; | |
| 238 | // PowerShell 5.1's Start-Process passes the argument string verbatim. | |
| 239 | assert.equal( | |
| 240 | decodedCommand, | |
| 241 | "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", | |
| 242 | ); | |
| 243 | assert.equal(spawned.options.shell, false); | |
| 244 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 245 | ); | |
| 246 | ||
| 247 | // Real-chain smoke check for the Explorer selection contract: runs the exact | |
| 248 | // PowerShell source the reveal launch encodes, against a stub that records | |
| 249 | // the raw argument tail it receives, and asserts a spaced path arrives as the | |
| 250 | // single `/select,"<path>"` switch. Mock argv assertions cannot prove this — | |
| 251 | // only Windows' own PowerShell -> CreateProcess quoting chain can, so the | |
| 252 | // test runs only where that chain exists. | |
| 253 | // oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. | |
| 254 | it.skipIf(process.platform !== "win32")( | |
| 255 | "delivers the raw /select switch for spaced paths through real PowerShell", | |
| 256 | { timeout: 60_000 }, | |
| 257 | async () => { | |
| 258 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); | |
| 259 | try { | |
| 260 | const recorderPath = NodePath.join(tempDir, "recorder.cmd"); | |
| 261 | const outputPath = NodePath.join(tempDir, "argv.txt"); | |
| 262 | NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); | |
| 263 | ||
| 264 | const target = "C:\\workspace with spaces\\media\\author's clip.mp4"; | |
| 265 | const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(recorderPath, target); | |
| 266 | const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; | |
| 267 | NodeChildProcess.execFileSync( | |
| 268 | powerShellPath, | |
| 269 | [ | |
| 270 | "-NoProfile", | |
| 271 | "-NonInteractive", | |
| 272 | "-ExecutionPolicy", | |
| 273 | "Bypass", | |
| 274 | "-EncodedCommand", | |
| 275 | Buffer.from(source, "utf16le").toString("base64"), | |
| 276 | ], | |
| 277 | { timeout: 30_000 }, | |
| 278 | ); | |
| 279 | ||
| 280 | // Start-Process returns before the recorder runs; wait for its output. | |
| 281 | // The waits run outside the Effect runtime on purpose: the test | |
| 282 | // exercises the real Windows process chain in real time. | |
| 283 | // @effect-diagnostics-next-line globalTimers:off | |
| 284 | const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); | |
| 285 | // @effect-diagnostics-next-line globalDate:off | |
| 286 | const deadline = Date.now() + 20_000; | |
| 287 | // @effect-diagnostics-next-line globalDate:off | |
| 288 | while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { | |
| 289 | await sleep(100); | |
| 290 | } | |
| 291 | await sleep(200); | |
| 292 | const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); | |
| 293 | assert.equal(recorded, `/select,"${target}"`); | |
| 294 | } finally { | |
| 295 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | |
| 296 | } | |
| 297 | }, | |
| 298 | ); | |
| 299 | ||
| 300 | it.effect("does not advertise reveal on Windows when PowerShell is missing", () => | |
| 301 | Effect.gen(function* () { | |
| 302 | const fileSystem = yield* FileSystem.FileSystem; | |
| 303 | const path = yield* Path.Path; | |
| 304 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 305 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); | |
| 306 | ||
| 307 | const result = yield* Effect.gen(function* () { | |
| 308 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 309 | return { | |
| 310 | kind: yield* launcher.resolveFileManagerRevealKind(), | |
| 311 | editors: yield* launcher.resolveAvailableEditors(), | |
| 312 | }; | |
| 313 | }).pipe( | |
| 314 | Effect.provide( | |
| 315 | testLayer({ | |
| 316 | platform: "win32", | |
| 317 | env: { | |
| 318 | PATH: binDir, | |
| 319 | PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 320 | SYSTEMROOT: path.join(binDir, "missing-system-root"), | |
| 321 | }, | |
| 322 | }), | |
| 323 | ), | |
| 324 | ); | |
| 325 | ||
| 326 | // Plain "open in file manager" still works through explorer; only the | |
| 327 | // reveal capability, which launches PowerShell, must stay hidden. | |
| 328 | assert.equal(result.editors.includes("file-manager"), true); | |
| 329 | assert.isUndefined(result.kind); | |
| 330 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 331 | ); | |
| 332 | ||
| 333 | it.effect("reveals a WSL file in Windows File Explorer through its UNC path", () => | |
| 334 | Effect.gen(function* () { | |
| 335 | const fileSystem = yield* FileSystem.FileSystem; | |
| 336 | const path = yield* Path.Path; | |
| 337 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 338 | for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { | |
| 339 | const filePath = path.join(binDir, name); | |
| 340 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 341 | yield* fileSystem.chmod(filePath, 0o755); | |
| 342 | } | |
| 343 | ||
| 344 | let spawned: ChildProcess.StandardCommand | undefined; | |
| 345 | const result = yield* Effect.gen(function* () { | |
| 346 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 347 | const kind = yield* launcher.resolveFileManagerRevealKind(); | |
| 348 | const editors = yield* launcher.resolveAvailableEditors(); | |
| 349 | yield* launcher.launchEditor({ | |
| 350 | editor: "file-manager", | |
| 351 | cwd: "/home/t3/workspace/media/clip.mp4", | |
| 352 | reveal: true, | |
| 353 | }); | |
| 354 | return { kind, editors }; | |
| 355 | }).pipe( | |
| 356 | Effect.provide( | |
| 357 | testLayer({ | |
| 358 | platform: "linux", | |
| 359 | env: { | |
| 360 | PATH: binDir, | |
| 361 | WSL_DISTRO_NAME: "Ubuntu-24.04", | |
| 362 | WSL_INTEROP: "/run/WSL/1_interop", | |
| 363 | }, | |
| 364 | onSpawn: (command) => { | |
| 365 | spawned = command; | |
| 366 | }, | |
| 367 | }), | |
| 368 | ), | |
| 369 | ); | |
| 370 | ||
| 371 | assert.equal(result.kind, "file-explorer"); | |
| 372 | assert.equal(result.editors.includes("file-manager"), true); | |
| 373 | assert.ok(spawned); | |
| 374 | // The reveal routes through interop PowerShell so Explorer receives its | |
| 375 | // raw `/select,"<path>"` switch even for spaced paths. | |
| 376 | assert.equal(spawned.command, "powershell.exe"); | |
| 377 | const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; | |
| 378 | const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); | |
| 379 | assert.equal( | |
| 380 | decodedCommand, | |
| 381 | "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", | |
| 382 | ); | |
| 383 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 384 | ); | |
| 385 | ||
| 386 | it.effect("does not advertise reveal from WSL when interop PowerShell is missing", () => | |
| 387 | Effect.gen(function* () { | |
| 388 | const fileSystem = yield* FileSystem.FileSystem; | |
| 389 | const path = yield* Path.Path; | |
| 390 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 391 | const explorerPath = path.join(binDir, "explorer.exe"); | |
| 392 | yield* fileSystem.writeFileString(explorerPath, ""); | |
| 393 | yield* fileSystem.chmod(explorerPath, 0o755); | |
| 394 | ||
| 395 | const result = yield* Effect.gen(function* () { | |
| 396 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 397 | return { | |
| 398 | kind: yield* launcher.resolveFileManagerRevealKind(), | |
| 399 | editors: yield* launcher.resolveAvailableEditors(), | |
| 400 | }; | |
| 401 | }).pipe( | |
| 402 | Effect.provide( | |
| 403 | testLayer({ | |
| 404 | platform: "linux", | |
| 405 | env: { | |
| 406 | PATH: binDir, | |
| 407 | WSL_DISTRO_NAME: "Ubuntu-24.04", | |
| 408 | WSL_INTEROP: "/run/WSL/1_interop", | |
| 409 | }, | |
| 410 | }), | |
| 411 | ), | |
| 412 | ); | |
| 413 | ||
| 414 | assert.equal(result.editors.includes("file-manager"), true); | |
| 415 | assert.isUndefined(result.kind); | |
| 416 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 417 | ); | |
| 418 | ||
| 419 | // When interop PowerShell is missing the capability advertises the Linux | |
| 420 | // "files" kind (or nothing), so the reveal must open the Linux file manager | |
| 421 | // the label promised even though plain open still prefers File Explorer. | |
| 422 | it.effect("reveals through the Linux file manager when WSL lacks interop PowerShell", () => | |
| 423 | Effect.gen(function* () { | |
| 424 | const fileSystem = yield* FileSystem.FileSystem; | |
| 425 | const path = yield* Path.Path; | |
| 426 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 427 | for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { | |
| 428 | const filePath = path.join(binDir, name); | |
| 429 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 430 | yield* fileSystem.chmod(filePath, 0o755); | |
| 431 | } | |
| 432 | ||
| 433 | const spawnedCommands: ChildProcess.StandardCommand[] = []; | |
| 434 | const kind = yield* Effect.gen(function* () { | |
| 435 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 436 | const revealKind = yield* launcher.resolveFileManagerRevealKind(); | |
| 437 | yield* launcher.launchEditor({ | |
| 438 | editor: "file-manager", | |
| 439 | cwd: "/home/t3/workspace/media/clip.mp4", | |
| 440 | reveal: true, | |
| 441 | }); | |
| 442 | return revealKind; | |
| 443 | }).pipe( | |
| 444 | Effect.provide( | |
| 445 | testLayer({ | |
| 446 | platform: "linux", | |
| 447 | env: { | |
| 448 | PATH: binDir, | |
| 449 | WSL_DISTRO_NAME: "Ubuntu-24.04", | |
| 450 | WSL_INTEROP: "/run/WSL/1_interop", | |
| 451 | DISPLAY: ":0", | |
| 452 | }, | |
| 453 | onSpawn: (command) => { | |
| 454 | spawnedCommands.push(command); | |
| 455 | }, | |
| 456 | spawnResult: (command) => | |
| 457 | command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, | |
| 458 | }), | |
| 459 | ), | |
| 460 | ); | |
| 461 | ||
| 462 | assert.equal(kind, "files"); | |
| 463 | const launch = spawnedCommands.find((command) => command.command === "xdg-open"); | |
| 464 | assert.ok(launch); | |
| 465 | assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); | |
| 466 | assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); | |
| 467 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 468 | ); | |
| 469 | ||
| 470 | // Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false) | |
| 471 | // while WSLg still provides a working Linux file manager; the host must keep | |
| 472 | // the Linux open/reveal path instead of losing the editor entirely. | |
| 473 | it.effect("falls back to the Linux file manager when WSL lacks the Explorer bridge", () => | |
| 474 | Effect.gen(function* () { | |
| 475 | const fileSystem = yield* FileSystem.FileSystem; | |
| 476 | const path = yield* Path.Path; | |
| 477 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 478 | for (const name of ["xdg-open", "xdg-mime"]) { | |
| 479 | const filePath = path.join(binDir, name); | |
| 480 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 481 | yield* fileSystem.chmod(filePath, 0o755); | |
| 482 | } | |
| 483 | ||
| 484 | const spawnedCommands: ChildProcess.StandardCommand[] = []; | |
| 485 | const result = yield* Effect.gen(function* () { | |
| 486 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 487 | const editors = yield* launcher.resolveAvailableEditors(); | |
| 488 | const kind = yield* launcher.resolveFileManagerRevealKind(); | |
| 489 | yield* launcher.launchEditor({ | |
| 490 | editor: "file-manager", | |
| 491 | cwd: "/home/t3/workspace/media/clip.mp4", | |
| 492 | reveal: true, | |
| 493 | }); | |
| 494 | return { editors, kind }; | |
| 495 | }).pipe( | |
| 496 | Effect.provide( | |
| 497 | testLayer({ | |
| 498 | platform: "linux", | |
| 499 | env: { | |
| 500 | PATH: binDir, | |
| 501 | WSL_DISTRO_NAME: "Ubuntu-24.04", | |
| 502 | WSL_INTEROP: "/run/WSL/1_interop", | |
| 503 | DISPLAY: ":0", | |
| 504 | }, | |
| 505 | onSpawn: (command) => { | |
| 506 | spawnedCommands.push(command); | |
| 507 | }, | |
| 508 | spawnResult: (command) => | |
| 509 | command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, | |
| 510 | }), | |
| 511 | ), | |
| 512 | ); | |
| 513 | ||
| 514 | assert.equal(result.editors.includes("file-manager"), true); | |
| 515 | assert.equal(result.kind, "files"); | |
| 516 | const launch = spawnedCommands.find((command) => command.command === "xdg-open"); | |
| 517 | assert.ok(launch); | |
| 518 | assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); | |
| 519 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 520 | ); | |
| 521 | ||
| 522 | it.effect( | |
| 523 | "falls back to opening the containing directory for WSL paths Explorer cannot select", | |
| 524 | () => | |
| 525 | Effect.gen(function* () { | |
| 526 | const fileSystem = yield* FileSystem.FileSystem; | |
| 527 | const path = yield* Path.Path; | |
| 528 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 529 | for (const name of ["explorer.exe", "powershell.exe"]) { | |
| 530 | const filePath = path.join(binDir, name); | |
| 531 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 532 | yield* fileSystem.chmod(filePath, 0o755); | |
| 533 | } | |
| 534 | ||
| 535 | let spawned: ChildProcess.StandardCommand | undefined; | |
| 536 | yield* Effect.gen(function* () { | |
| 537 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 538 | yield* launcher.launchEditor({ | |
| 539 | editor: "file-manager", | |
| 540 | cwd: '/home/t3/work "quoted"/clip.mp4', | |
| 541 | reveal: true, | |
| 542 | }); | |
| 543 | }).pipe( | |
| 544 | Effect.provide( | |
| 545 | testLayer({ | |
| 546 | platform: "linux", | |
| 547 | env: { | |
| 548 | PATH: binDir, | |
| 549 | WSL_DISTRO_NAME: "Ubuntu-24.04", | |
| 550 | WSL_INTEROP: "/run/WSL/1_interop", | |
| 551 | }, | |
| 552 | onSpawn: (command) => { | |
| 553 | spawned = command; | |
| 554 | }, | |
| 555 | }), | |
| 556 | ), | |
| 557 | ); | |
| 558 | ||
| 559 | // Explorer's raw switch cannot express a double quote, so the launch | |
| 560 | // opens the parent directory instead of misparsing a /select argument. | |
| 561 | assert.ok(spawned); | |
| 562 | assert.equal(spawned.command, "explorer.exe"); | |
| 563 | assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); | |
| 564 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 565 | ); | |
| 566 | ||
| 567 | it.effect("reveals by opening the containing directory on Linux", () => | |
| 568 | Effect.gen(function* () { | |
| 569 | const fileSystem = yield* FileSystem.FileSystem; | |
| 570 | const path = yield* Path.Path; | |
| 571 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 572 | for (const name of ["xdg-open", "xdg-mime"]) { | |
| 573 | const filePath = path.join(binDir, name); | |
| 574 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 575 | yield* fileSystem.chmod(filePath, 0o755); | |
| 576 | } | |
| 577 | ||
| 578 | const spawnedCommands: ChildProcess.StandardCommand[] = []; | |
| 579 | yield* Effect.gen(function* () { | |
| 580 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 581 | yield* launcher.launchEditor({ | |
| 582 | editor: "file-manager", | |
| 583 | cwd: "/workspace/media/linux-mini-v2.mp4", | |
| 584 | reveal: true, | |
| 585 | }); | |
| 586 | }).pipe( | |
| 587 | Effect.provide( | |
| 588 | testLayer({ | |
| 589 | platform: "linux", | |
| 590 | env: { PATH: binDir, DISPLAY: ":0" }, | |
| 591 | onSpawn: (command) => { | |
| 592 | spawnedCommands.push(command); | |
| 593 | }, | |
| 594 | spawnResult: (command) => | |
| 595 | command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, | |
| 596 | }), | |
| 597 | ), | |
| 598 | ); | |
| 599 | ||
| 600 | const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); | |
| 601 | assert.ok(spawned); | |
| 602 | assert.deepEqual(spawned.args, ["/workspace/media"]); | |
| 603 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 604 | ); | |
| 605 | ||
| 606 | it.effect("does not advertise a Linux file manager without a graphical session", () => | |
| 607 | Effect.gen(function* () { | |
| 608 | const fileSystem = yield* FileSystem.FileSystem; | |
| 609 | const path = yield* Path.Path; | |
| 610 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 611 | const xdgOpenPath = path.join(binDir, "xdg-open"); | |
| 612 | yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); | |
| 613 | yield* fileSystem.chmod(xdgOpenPath, 0o755); | |
| 614 | ||
| 615 | const editors = yield* Effect.gen(function* () { | |
| 616 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 617 | return yield* launcher.resolveAvailableEditors(); | |
| 618 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); | |
| 619 | ||
| 620 | assert.equal(editors.includes("file-manager"), false); | |
| 621 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 622 | ); | |
| 623 | ||
| 624 | it.effect("advertises a Linux file manager when a directory handler is installed", () => | |
| 625 | Effect.gen(function* () { | |
| 626 | const fileSystem = yield* FileSystem.FileSystem; | |
| 627 | const path = yield* Path.Path; | |
| 628 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 629 | for (const name of ["xdg-open", "xdg-mime"]) { | |
| 630 | const filePath = path.join(binDir, name); | |
| 631 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 632 | yield* fileSystem.chmod(filePath, 0o755); | |
| 633 | } | |
| 634 | ||
| 635 | let probe: ChildProcess.StandardCommand | undefined; | |
| 636 | const editors = yield* Effect.gen(function* () { | |
| 637 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 638 | return yield* launcher.resolveAvailableEditors(); | |
| 639 | }).pipe( | |
| 640 | Effect.provide( | |
| 641 | testLayer({ | |
| 642 | platform: "linux", | |
| 643 | env: { PATH: binDir, DISPLAY: ":0" }, | |
| 644 | onSpawn: (command) => { | |
| 645 | probe = command; | |
| 646 | }, | |
| 647 | spawnResult: (command) => | |
| 648 | command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, | |
| 649 | }), | |
| 650 | ), | |
| 651 | ); | |
| 652 | ||
| 653 | assert.equal(editors.includes("file-manager"), true); | |
| 654 | assert.ok(probe); | |
| 655 | assert.equal(probe.command, "xdg-mime"); | |
| 656 | assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); | |
| 657 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 658 | ); | |
| 659 | ||
| 660 | // `xdg-open` with a display variable but no `inode/directory` handler exits | |
| 661 | // nonzero after the launch has already detached: without this gate the server | |
| 662 | // advertises a reveal that is a silent no-op. | |
| 663 | it.effect("does not advertise a Linux file manager without a directory handler", () => | |
| 664 | Effect.gen(function* () { | |
| 665 | const fileSystem = yield* FileSystem.FileSystem; | |
| 666 | const path = yield* Path.Path; | |
| 667 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 668 | for (const name of ["xdg-open", "xdg-mime"]) { | |
| 669 | const filePath = path.join(binDir, name); | |
| 670 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 671 | yield* fileSystem.chmod(filePath, 0o755); | |
| 672 | } | |
| 673 | ||
| 674 | const editors = yield* Effect.gen(function* () { | |
| 675 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 676 | return yield* launcher.resolveAvailableEditors(); | |
| 677 | }).pipe( | |
| 678 | Effect.provide( | |
| 679 | testLayer({ | |
| 680 | platform: "linux", | |
| 681 | env: { PATH: binDir, DISPLAY: ":0" }, | |
| 682 | spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), | |
| 683 | }), | |
| 684 | ), | |
| 685 | ); | |
| 686 | ||
| 687 | assert.equal(editors.includes("file-manager"), false); | |
| 688 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 689 | ); | |
| 690 | ||
| 691 | it.effect("does not advertise a Linux file manager when the handler query fails", () => | |
| 692 | Effect.gen(function* () { | |
| 693 | const fileSystem = yield* FileSystem.FileSystem; | |
| 694 | const path = yield* Path.Path; | |
| 695 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 696 | for (const name of ["xdg-open", "xdg-mime"]) { | |
| 697 | const filePath = path.join(binDir, name); | |
| 698 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 699 | yield* fileSystem.chmod(filePath, 0o755); | |
| 700 | } | |
| 701 | ||
| 702 | const editors = yield* Effect.gen(function* () { | |
| 703 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 704 | return yield* launcher.resolveAvailableEditors(); | |
| 705 | }).pipe( | |
| 706 | Effect.provide( | |
| 707 | testLayer({ | |
| 708 | platform: "linux", | |
| 709 | env: { PATH: binDir, DISPLAY: ":0" }, | |
| 710 | spawnResult: (command) => | |
| 711 | command.command === "xdg-mime" | |
| 712 | ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } | |
| 713 | : undefined, | |
| 714 | }), | |
| 715 | ), | |
| 716 | ); | |
| 717 | ||
| 718 | assert.equal(editors.includes("file-manager"), false); | |
| 719 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 720 | ); | |
| 721 | ||
| 722 | // The handler probe carries its own timeout because the editor scan's outer | |
| 723 | // timeout in server.getConfig degrades to an EMPTY editor list: a wedged | |
| 724 | // xdg-mime must cost only the file manager, never the other editors. Runs on | |
| 725 | // the live clock so the probe's real timeout fires. | |
| 726 | it.live("a stalled handler probe drops only the file manager", () => | |
| 727 | Effect.gen(function* () { | |
| 728 | const fileSystem = yield* FileSystem.FileSystem; | |
| 729 | const path = yield* Path.Path; | |
| 730 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 731 | for (const name of ["xdg-open", "xdg-mime", "code"]) { | |
| 732 | const filePath = path.join(binDir, name); | |
| 733 | yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); | |
| 734 | yield* fileSystem.chmod(filePath, 0o755); | |
| 735 | } | |
| 736 | ||
| 737 | const editors = yield* Effect.gen(function* () { | |
| 738 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 739 | return yield* launcher.resolveAvailableEditors(); | |
| 740 | }).pipe( | |
| 741 | Effect.provide( | |
| 742 | testLayer({ | |
| 743 | platform: "linux", | |
| 744 | env: { PATH: binDir, DISPLAY: ":0" }, | |
| 745 | spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), | |
| 746 | }), | |
| 747 | ), | |
| 748 | ); | |
| 749 | ||
| 750 | assert.equal(editors.includes("vscode"), true); | |
| 751 | assert.equal(editors.includes("file-manager"), false); | |
| 752 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 753 | ); | |
| 754 | ||
| 755 | it.effect("does not advertise a Linux file manager when xdg-mime is missing", () => | |
| 756 | Effect.gen(function* () { | |
| 757 | const fileSystem = yield* FileSystem.FileSystem; | |
| 758 | const path = yield* Path.Path; | |
| 759 | const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 760 | const xdgOpenPath = path.join(binDir, "xdg-open"); | |
| 761 | yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); | |
| 762 | yield* fileSystem.chmod(xdgOpenPath, 0o755); | |
| 763 | ||
| 764 | const editors = yield* Effect.gen(function* () { | |
| 765 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 766 | return yield* launcher.resolveAvailableEditors(); | |
| 767 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } }))); | |
| 768 | ||
| 769 | assert.equal(editors.includes("file-manager"), false); | |
| 770 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 771 | ); | |
| 772 | ||
| 135 | 773 | it.effect("discovers editors through the service API", () => |
| 136 | 774 | Effect.gen(function* () { |
| 137 | 775 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/process/externalLauncher.ts+312-19| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | ExternalLauncherUnknownEditorError, |
| 16 | 16 | ExternalLauncherUnsupportedEditorError, |
| 17 | 17 | type EditorId, |
| 18 | type FileManagerRevealKind, | |
| 18 | 19 | type LaunchEditorInput, |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| ... | ... | @@ -29,6 +30,7 @@ import * as Layer from "effect/Layer"; |
| 29 | 30 | import * as Option from "effect/Option"; |
| 30 | 31 | import * as Path from "effect/Path"; |
| 31 | 32 | import * as Ref from "effect/Ref"; |
| 33 | import * as Stream from "effect/Stream"; | |
| 32 | 34 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 33 | 35 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 34 | 36 | |
| ... | ... | @@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({ |
| 99 | 101 | SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), |
| 100 | 102 | SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), |
| 101 | 103 | container: Config.string("container").pipe(Config.option), |
| 104 | DISPLAY: Config.string("DISPLAY").pipe(Config.option), | |
| 105 | WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), | |
| 102 | 106 | }).pipe(Config.map(compactEnv)); |
| 103 | 107 | |
| 104 | 108 | const CommandLookupEnvConfig = Config.all({ |
| ... | ... | @@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string { |
| 193 | 197 | return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; |
| 194 | 198 | } |
| 195 | 199 | |
| 196 | function shouldUseWindowsBrowserFromWsl( | |
| 200 | // File reveals from WSL resolve PowerShell through the interop PATH rather | |
| 201 | // than the fixed /mnt/c mount: the automount root is configurable, and a | |
| 202 | // PATH-resolved command keeps the advertised capability aligned with the | |
| 203 | // availability check `launchEditor` performs before spawning. | |
| 204 | const WSL_POWERSHELL_COMMAND = "powershell.exe"; | |
| 205 | ||
| 206 | function shouldUseWindowsHostFromWsl( | |
| 197 | 207 | platform: NodeJS.Platform, |
| 198 | 208 | env: NodeJS.ProcessEnv = {}, |
| 199 | 209 | ): boolean { |
| ... | ... | @@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa |
| 223 | 233 | }; |
| 224 | 234 | } |
| 225 | 235 | |
| 226 | function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { | |
| 236 | function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { | |
| 237 | return [env.DISPLAY, env.WAYLAND_DISPLAY].some( | |
| 238 | (value) => value !== undefined && value.trim().length > 0, | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 242 | function fileManagerCommandForPlatform( | |
| 243 | platform: NodeJS.Platform, | |
| 244 | env: NodeJS.ProcessEnv, | |
| 245 | ): string | undefined { | |
| 227 | 246 | switch (platform) { |
| 228 | 247 | case "darwin": |
| 229 | 248 | return "open"; |
| 230 | 249 | case "win32": |
| 231 | 250 | return "explorer"; |
| 232 | 251 | default: |
| 233 | return "xdg-open"; | |
| 252 | if (shouldUseWindowsHostFromWsl(platform, env)) { | |
| 253 | return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; | |
| 254 | } | |
| 255 | return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; | |
| 234 | 256 | } |
| 235 | 257 | } |
| 236 | 258 | |
| 259 | // A graphical session variable plus an executable `xdg-open` does not prove | |
| 260 | // that opening a directory does anything: without an `inode/directory` MIME | |
| 261 | // handler, `xdg-open` exits nonzero after the launcher has already detached, | |
| 262 | // so the client would see a silent no-op. Require the handler before | |
| 263 | // advertising the file manager on Linux. | |
| 264 | // | |
| 265 | // The probe carries its own timeout well inside the scan timeout | |
| 266 | // `server.getConfig` applies to editor discovery: that outer timeout degrades | |
| 267 | // to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop | |
| 268 | // session) must cost only the file manager, not every discovered editor. | |
| 269 | const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; | |
| 270 | ||
| 271 | const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( | |
| 272 | function* ( | |
| 273 | env: NodeJS.ProcessEnv, | |
| 274 | ): Effect.fn.Return< | |
| 275 | boolean, | |
| 276 | never, | |
| 277 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 278 | > { | |
| 279 | if (!(yield* isCommandAvailable("xdg-mime", { env }))) { | |
| 280 | return false; | |
| 281 | } | |
| 282 | ||
| 283 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 284 | return yield* spawner | |
| 285 | .spawn( | |
| 286 | ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { | |
| 287 | stdin: "ignore", | |
| 288 | stderr: "ignore", | |
| 289 | }), | |
| 290 | ) | |
| 291 | .pipe( | |
| 292 | Effect.flatMap((handle) => | |
| 293 | Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { | |
| 294 | concurrency: "unbounded", | |
| 295 | }), | |
| 296 | ), | |
| 297 | Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), | |
| 298 | Effect.scoped, | |
| 299 | Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), | |
| 300 | Effect.orElseSucceed(() => false), | |
| 301 | ); | |
| 302 | }, | |
| 303 | ); | |
| 304 | ||
| 305 | const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( | |
| 306 | function* ( | |
| 307 | command: string, | |
| 308 | env: NodeJS.ProcessEnv, | |
| 309 | ): Effect.fn.Return< | |
| 310 | boolean, | |
| 311 | never, | |
| 312 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 313 | > { | |
| 314 | if (!(yield* isCommandAvailable(command, { env }))) { | |
| 315 | return false; | |
| 316 | } | |
| 317 | return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); | |
| 318 | }, | |
| 319 | ); | |
| 320 | ||
| 321 | // The file-manager command a launch can actually run, not just the platform | |
| 322 | // preference. WSL hosts prefer the Windows Explorer bridge, but interop can | |
| 323 | // exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a | |
| 324 | // distro name while WSLg still provides a working Linux file manager, so they | |
| 325 | // keep the `xdg-open` fallback instead of losing the editor entirely. | |
| 326 | const resolveUsableFileManagerCommand = Effect.fn( | |
| 327 | "externalLauncher.resolveUsableFileManagerCommand", | |
| 328 | )(function* ( | |
| 329 | platform: NodeJS.Platform, | |
| 330 | env: NodeJS.ProcessEnv, | |
| 331 | ): Effect.fn.Return< | |
| 332 | string | undefined, | |
| 333 | never, | |
| 334 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 335 | > { | |
| 336 | const command = fileManagerCommandForPlatform(platform, env); | |
| 337 | if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { | |
| 338 | return command; | |
| 339 | } | |
| 340 | if ( | |
| 341 | shouldUseWindowsHostFromWsl(platform, env) && | |
| 342 | hasGraphicalLinuxSession(env) && | |
| 343 | (yield* isUsableFileManagerCommand("xdg-open", env)) | |
| 344 | ) { | |
| 345 | return "xdg-open"; | |
| 346 | } | |
| 347 | return undefined; | |
| 348 | }); | |
| 349 | ||
| 350 | // Reveal on Windows and WSL runs through PowerShell (see | |
| 351 | // resolveFileManagerRevealLaunch), not the `explorer` command that gates the | |
| 352 | // file-manager editor itself, so the capability must probe the executables the | |
| 353 | // reveal actually spawns. Callers gate on file-manager availability first; | |
| 354 | // the Linux "files" kind relies on that gate for the directory-handler probe, | |
| 355 | // while the WSL fallback re-probes because its availability may have come | |
| 356 | // from the Explorer bridge instead. | |
| 357 | const fileManagerRevealKindForPlatform = Effect.fn( | |
| 358 | "externalLauncher.fileManagerRevealKindForPlatform", | |
| 359 | )(function* ( | |
| 360 | platform: NodeJS.Platform, | |
| 361 | env: NodeJS.ProcessEnv, | |
| 362 | ): Effect.fn.Return< | |
| 363 | FileManagerRevealKind | undefined, | |
| 364 | never, | |
| 365 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 366 | > { | |
| 367 | if (platform === "darwin") return "finder"; | |
| 368 | if (platform === "win32") { | |
| 369 | return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) | |
| 370 | ? "file-explorer" | |
| 371 | : undefined; | |
| 372 | } | |
| 373 | if (shouldUseWindowsHostFromWsl(platform, env)) { | |
| 374 | if ( | |
| 375 | env.WSL_DISTRO_NAME?.trim() && | |
| 376 | (yield* isCommandAvailable("explorer.exe", { env })) && | |
| 377 | (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) | |
| 378 | ) { | |
| 379 | return "file-explorer"; | |
| 380 | } | |
| 381 | return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) | |
| 382 | ? "files" | |
| 383 | : undefined; | |
| 384 | } | |
| 385 | return hasGraphicalLinuxSession(env) ? "files" : undefined; | |
| 386 | }); | |
| 387 | ||
| 388 | function resolveWslFileManagerPath(target: string, distroName: string): string { | |
| 389 | const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); | |
| 390 | return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; | |
| 391 | } | |
| 392 | ||
| 237 | 393 | function buildBrowserLaunch( |
| 238 | 394 | target: string, |
| 239 | 395 | platform: NodeJS.Platform, |
| ... | ... | @@ -251,7 +407,7 @@ function buildBrowserLaunch( |
| 251 | 407 | return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); |
| 252 | 408 | } |
| 253 | 409 | |
| 254 | if (shouldUseWindowsBrowserFromWsl(platform, env)) { | |
| 410 | if (shouldUseWindowsHostFromWsl(platform, env)) { | |
| 255 | 411 | return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); |
| 256 | 412 | } |
| 257 | 413 | |
| ... | ... | @@ -265,13 +421,16 @@ function buildBrowserLaunch( |
| 265 | 421 | const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( |
| 266 | 422 | platform: NodeJS.Platform, |
| 267 | 423 | env: NodeJS.ProcessEnv, |
| 268 | ): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> { | |
| 424 | ): Effect.fn.Return< | |
| 425 | ReadonlyArray<EditorId>, | |
| 426 | never, | |
| 427 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 428 | > { | |
| 269 | 429 | const available: EditorId[] = []; |
| 270 | 430 | |
| 271 | 431 | for (const editor of EDITORS) { |
| 272 | 432 | if (editor.commands === null) { |
| 273 | const command = fileManagerCommandForPlatform(platform); | |
| 274 | if (yield* isCommandAvailable(command, { env })) { | |
| 433 | if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { | |
| 275 | 434 | available.push(editor.id); |
| 276 | 435 | } |
| 277 | 436 | continue; |
| ... | ... | @@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( |
| 296 | 455 | |
| 297 | 456 | const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { |
| 298 | 457 | const platform = yield* HostProcessPlatform; |
| 299 | const env = yield* readCommandLookupEnv; | |
| 458 | const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; | |
| 300 | 459 | return yield* buildAvailableEditors(platform, env); |
| 301 | 460 | }); |
| 302 | 461 | |
| 462 | const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( | |
| 463 | function* () { | |
| 464 | const platform = yield* HostProcessPlatform; | |
| 465 | const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; | |
| 466 | return yield* fileManagerRevealKindForPlatform(platform, env); | |
| 467 | }, | |
| 468 | ); | |
| 469 | ||
| 303 | 470 | // Editor discovery walks PATH for every known editor and runs for every |
| 304 | 471 | // client connect (the server config embeds the available editors). Memoize |
| 305 | 472 | // the discovered set for a bounded window so repeat connects skip even the |
| ... | ... | @@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service< |
| 329 | 496 | ExternalLauncher, |
| 330 | 497 | { |
| 331 | 498 | readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>; |
| 499 | /** | |
| 500 | * Reveal kind for the host, or undefined when the executable a reveal | |
| 501 | * actually spawns is unavailable. Only meaningful when | |
| 502 | * `resolveAvailableEditors` includes "file-manager": on Linux that | |
| 503 | * availability check also carries the directory-handler probe this | |
| 504 | * capability relies on. | |
| 505 | */ | |
| 506 | readonly resolveFileManagerRevealKind: () => Effect.Effect<FileManagerRevealKind | undefined>; | |
| 332 | 507 | /** Launch a URL target in the default browser. */ |
| 333 | 508 | readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>; |
| 334 | 509 | /** |
| ... | ... | @@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service< |
| 346 | 521 | |
| 347 | 522 | const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 348 | 523 | input: LaunchEditorInput, |
| 349 | ): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> { | |
| 524 | ): Effect.fn.Return< | |
| 525 | EditorLaunch, | |
| 526 | ExternalLauncherError, | |
| 527 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 528 | > { | |
| 350 | 529 | const platform = yield* HostProcessPlatform; |
| 351 | const env = yield* readCommandLookupEnv; | |
| 530 | const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; | |
| 352 | 531 | yield* Effect.annotateCurrentSpan({ |
| 353 | 532 | "externalLauncher.editor": input.editor, |
| 354 | 533 | "externalLauncher.cwd": input.cwd, |
| ... | ... | @@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 376 | 555 | return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); |
| 377 | 556 | } |
| 378 | 557 | |
| 558 | const command = yield* resolveUsableFileManagerCommand(platform, env); | |
| 559 | if (command === undefined) { | |
| 560 | return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); | |
| 561 | } | |
| 562 | ||
| 563 | if (input.reveal === true) { | |
| 564 | return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); | |
| 565 | } | |
| 566 | ||
| 379 | 567 | return { |
| 380 | 568 | editor: editorDef.id, |
| 381 | 569 | target: input.cwd, |
| 382 | command: fileManagerCommandForPlatform(platform), | |
| 383 | args: [input.cwd], | |
| 570 | command, | |
| 571 | args: | |
| 572 | command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined | |
| 573 | ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] | |
| 574 | : [input.cwd], | |
| 384 | 575 | }; |
| 385 | 576 | }); |
| 386 | 577 | |
| 578 | /** | |
| 579 | * PowerShell source that launches File Explorer with its raw selection | |
| 580 | * switch. Explorer's contract is the single argument `/select,"<path>"` with | |
| 581 | * only the path quoted; Node's default spawn quoting wraps the whole argument | |
| 582 | * when the path has spaces and Explorer misparses it, silently opening a | |
| 583 | * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1 | |
| 584 | * reaches the child's command line verbatim, preserving the raw switch. | |
| 585 | * | |
| 586 | * Exported so the Windows smoke test can drive the identical source through a | |
| 587 | * real PowerShell against a recording stub instead of Explorer. | |
| 588 | */ | |
| 589 | export function buildFileExplorerRevealPowerShellSource( | |
| 590 | explorerCommand: string, | |
| 591 | target: string, | |
| 592 | ): string { | |
| 593 | return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; | |
| 594 | } | |
| 595 | ||
| 596 | function fileExplorerRevealLaunch( | |
| 597 | target: string, | |
| 598 | explorerTarget: string, | |
| 599 | powershellCommand: string, | |
| 600 | ): EditorLaunch { | |
| 601 | return { | |
| 602 | editor: "file-manager", | |
| 603 | target, | |
| 604 | command: powershellCommand, | |
| 605 | args: [ | |
| 606 | ...POWERSHELL_ARGUMENTS_PREFIX, | |
| 607 | encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), | |
| 608 | ], | |
| 609 | }; | |
| 610 | } | |
| 611 | ||
| 612 | const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* ( | |
| 613 | target: string, | |
| 614 | platform: NodeJS.Platform, | |
| 615 | env: NodeJS.ProcessEnv, | |
| 616 | // The command resolveUsableFileManagerCommand picked; a WSL host that fell | |
| 617 | // back to the Linux file manager must reveal through it as well. | |
| 618 | command: string, | |
| 619 | ): Effect.fn.Return< | |
| 620 | EditorLaunch, | |
| 621 | never, | |
| 622 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | |
| 623 | > { | |
| 624 | if (platform === "darwin") { | |
| 625 | return { editor: "file-manager", target, command: "open", args: ["-R", target] }; | |
| 626 | } | |
| 627 | ||
| 628 | if (platform === "win32") { | |
| 629 | return fileExplorerRevealLaunch(target, target, resolvePowerShellPath(env)); | |
| 630 | } | |
| 631 | ||
| 632 | if ( | |
| 633 | command === "explorer.exe" && | |
| 634 | shouldUseWindowsHostFromWsl(platform, env) && | |
| 635 | env.WSL_DISTRO_NAME !== undefined | |
| 636 | ) { | |
| 637 | const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); | |
| 638 | if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { | |
| 639 | // Explorer's raw switch cannot express a double quote, and unlike | |
| 640 | // Windows paths a WSL path may legally contain one: open the containing | |
| 641 | // directory in File Explorer instead, matching the advertised | |
| 642 | // "file-explorer" kind. | |
| 643 | if (explorerTarget.includes('"')) { | |
| 644 | const path = yield* Path.Path; | |
| 645 | return { | |
| 646 | editor: "file-manager", | |
| 647 | target, | |
| 648 | command: "explorer.exe", | |
| 649 | args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], | |
| 650 | }; | |
| 651 | } | |
| 652 | return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); | |
| 653 | } | |
| 654 | // Without interop PowerShell the capability advertised the Linux "files" | |
| 655 | // kind when it advertised anything at all, so the reveal must open the | |
| 656 | // Linux file manager the label promised, not File Explorer. | |
| 657 | if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { | |
| 658 | const path = yield* Path.Path; | |
| 659 | return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] }; | |
| 660 | } | |
| 661 | // Nothing was advertised here; open the parent in File Explorer as the | |
| 662 | // best remaining effort for a stale client. | |
| 663 | const path = yield* Path.Path; | |
| 664 | return { | |
| 665 | editor: "file-manager", | |
| 666 | target, | |
| 667 | command: "explorer.exe", | |
| 668 | args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], | |
| 669 | }; | |
| 670 | } | |
| 671 | ||
| 672 | // Linux file managers have no portable "select this file" flag, so open | |
| 673 | // the containing directory instead. | |
| 674 | const path = yield* Path.Path; | |
| 675 | return { editor: "file-manager", target, command, args: [path.dirname(target)] }; | |
| 676 | }); | |
| 677 | ||
| 387 | 678 | const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( |
| 388 | 679 | launch: ProcessLaunch, |
| 389 | 680 | onError: (cause: unknown) => ExternalLauncherError, |
| ... | ... | @@ -476,7 +767,9 @@ export const make = Effect.gen(function* () { |
| 476 | 767 | if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { |
| 477 | 768 | return entry.value.editors; |
| 478 | 769 | } |
| 479 | const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); | |
| 770 | const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( | |
| 771 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 772 | ); | |
| 480 | 773 | yield* Ref.set( |
| 481 | 774 | editorDiscoveryCache, |
| 482 | 775 | Option.some({ |
| ... | ... | @@ -489,18 +782,18 @@ export const make = Effect.gen(function* () { |
| 489 | 782 | |
| 490 | 783 | return ExternalLauncher.of({ |
| 491 | 784 | resolveAvailableEditors: () => cachedAvailableEditors, |
| 785 | resolveFileManagerRevealKind: () => | |
| 786 | provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( | |
| 787 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 788 | ), | |
| 492 | 789 | launchBrowser: (target) => |
| 493 | 790 | launchBrowser(target).pipe( |
| 494 | 791 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 495 | 792 | ), |
| 496 | 793 | launchEditor: (input) => |
| 497 | 794 | provideCommandResolutionServices( |
| 498 | Effect.flatMap(resolveEditorLaunch(input), (launch) => | |
| 499 | launchEditorProcess(launch).pipe( | |
| 500 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 501 | ), | |
| 502 | ), | |
| 503 | ), | |
| 795 | Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess), | |
| 796 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), | |
| 504 | 797 | }); |
| 505 | 798 | }); |
| 506 | 799 |
apps/server/src/server.test.ts+53-2| ... | ... | @@ -102,7 +102,11 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function |
| 102 | 102 | import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; |
| 103 | 103 | import * as ServerConfig from "./config.ts"; |
| 104 | 104 | import { makeRoutesLayer } from "./server.ts"; |
| 105 | import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; | |
| 105 | import { | |
| 106 | isThreadDetailEvent, | |
| 107 | resolveAvailableEditorsForConfig, | |
| 108 | resolveFileManagerRevealKindForConfig, | |
| 109 | } from "./ws.ts"; | |
| 106 | 110 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 107 | 111 | import * as GitManager from "./git/GitManager.ts"; |
| 108 | 112 | import * as Keybindings from "./keybindings.ts"; |
| ... | ... | @@ -665,6 +669,7 @@ const buildAppUnderTest = (options?: { |
| 665 | 669 | Layer.mergeAll( |
| 666 | 670 | Layer.mock(ExternalLauncher.ExternalLauncher)({ |
| 667 | 671 | resolveAvailableEditors: () => Effect.succeed([]), |
| 672 | resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), | |
| 668 | 673 | ...options?.layers?.externalLauncher, |
| 669 | 674 | }), |
| 670 | 675 | Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ |
| ... | ... | @@ -4031,10 +4036,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4031 | 4036 | assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); |
| 4032 | 4037 | assert.equal(response.auth.policy, "desktop-managed-local"); |
| 4033 | 4038 | assert.equal(response.shellResumeCompletionMarker, true); |
| 4039 | assert.isUndefined(response.shellRevealInFileManager); | |
| 4040 | assert.isUndefined(response.shellRevealInFileManagerKind); | |
| 4034 | 4041 | assert.equal(response.threadResumeCompletionMarker, true); |
| 4035 | 4042 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4036 | 4043 | ); |
| 4037 | 4044 | |
| 4045 | it.effect("advertises the usable file manager and its reveal label", () => | |
| 4046 | Effect.gen(function* () { | |
| 4047 | yield* buildAppUnderTest({ | |
| 4048 | layers: { | |
| 4049 | externalLauncher: { | |
| 4050 | resolveAvailableEditors: () => Effect.succeed(["file-manager"]), | |
| 4051 | resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), | |
| 4052 | }, | |
| 4053 | }, | |
| 4054 | }); | |
| 4055 | ||
| 4056 | const { cookie } = yield* bootstrapBrowserSession(); | |
| 4057 | const wsUrl = appendSessionCookieToWsUrl( | |
| 4058 | yield* getWsServerUrl("/ws", { authenticated: false }), | |
| 4059 | cookie?.split(";")[0] ?? "", | |
| 4060 | ); | |
| 4061 | const response = yield* Effect.scoped( | |
| 4062 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), | |
| 4063 | ); | |
| 4064 | ||
| 4065 | assert.deepEqual(response.availableEditors, ["file-manager"]); | |
| 4066 | assert.equal(response.shellRevealInFileManager, true); | |
| 4067 | assert.equal(response.shellRevealInFileManagerKind, "file-explorer"); | |
| 4068 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 4069 | ); | |
| 4070 | ||
| 4038 | 4071 | it.effect("does not block server config when editor discovery never resolves", () => |
| 4039 | 4072 | Effect.gen(function* () { |
| 4040 | 4073 | const discoveryInterrupted = yield* Deferred.make<void>(); |
| ... | ... | @@ -4052,6 +4085,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4052 | 4085 | }), |
| 4053 | 4086 | ); |
| 4054 | 4087 | |
| 4088 | it.effect("does not block server config when file manager reveal discovery never resolves", () => | |
| 4089 | Effect.gen(function* () { | |
| 4090 | const discoveryInterrupted = yield* Deferred.make<void>(); | |
| 4091 | const responseFiber = yield* resolveFileManagerRevealKindForConfig( | |
| 4092 | Effect.never.pipe( | |
| 4093 | Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), | |
| 4094 | ), | |
| 4095 | ).pipe(Effect.forkChild); | |
| 4096 | ||
| 4097 | yield* TestClock.adjust(Duration.seconds(5)); | |
| 4098 | ||
| 4099 | const revealKind = yield* Fiber.join(responseFiber); | |
| 4100 | yield* Deferred.await(discoveryInterrupted); | |
| 4101 | assert.isUndefined(revealKind); | |
| 4102 | }), | |
| 4103 | ); | |
| 4104 | ||
| 4055 | 4105 | it.effect( |
| 4056 | 4106 | "rejects websocket rpc handshake when a session token is only provided via query string", |
| 4057 | 4107 | () => |
| ... | ... | @@ -4646,6 +4696,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4646 | 4696 | |
| 4647 | 4697 | it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => |
| 4648 | 4698 | Effect.gen(function* () { |
| 4699 | const path = yield* Path.Path; | |
| 4649 | 4700 | const providers = [ |
| 4650 | 4701 | { |
| 4651 | 4702 | instanceId: ProviderInstanceId.make("codex"), |
| ... | ... | @@ -4699,7 +4750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4699 | 4750 | assert.deepEqual(first.config.keybindings, []); |
| 4700 | 4751 | assert.deepEqual(first.config.issues, []); |
| 4701 | 4752 | assert.deepEqual(first.config.providers, providers); |
| 4702 | assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); | |
| 4753 | assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); | |
| 4703 | 4754 | assert.equal(first.config.observability.localTracingEnabled, true); |
| 4704 | 4755 | assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); |
| 4705 | 4756 | assert.equal(first.config.observability.otlpTracesEnabled, true); |
apps/server/src/ws.ts+31-8| ... | ... | @@ -19,6 +19,8 @@ import { |
| 19 | 19 | CommandId, |
| 20 | 20 | type DiscoveredLocalServerList, |
| 21 | 21 | EventId, |
| 22 | type EditorId, | |
| 23 | type FileManagerRevealKind, | |
| 22 | 24 | type OrchestrationClientOrigin, |
| 23 | 25 | type OrchestrationCommand, |
| 24 | 26 | type GitActionProgressEvent, |
| ... | ... | @@ -136,16 +138,25 @@ import * as RelayClient from "@t3tools/shared/relayClient"; |
| 136 | 138 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 137 | 139 | |
| 138 | 140 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 139 | const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5); | |
| 141 | const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); | |
| 140 | 142 | |
| 141 | export const resolveAvailableEditorsForConfig = <A, E, R>( | |
| 142 | discovery: Effect.Effect<ReadonlyArray<A>, E, R>, | |
| 143 | const resolveDiscoveryForConfig = <A, E, R>( | |
| 144 | discovery: Effect.Effect<A, E, R>, | |
| 145 | onTimeout: () => A, | |
| 143 | 146 | ) => |
| 144 | 147 | discovery.pipe( |
| 145 | Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT), | |
| 146 | Effect.map(Option.getOrElse(() => [])), | |
| 148 | Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT), | |
| 149 | Effect.map(Option.getOrElse(onTimeout)), | |
| 147 | 150 | ); |
| 148 | 151 | |
| 152 | export const resolveAvailableEditorsForConfig = <A, E, R>( | |
| 153 | discovery: Effect.Effect<ReadonlyArray<A>, E, R>, | |
| 154 | ) => resolveDiscoveryForConfig(discovery, () => []); | |
| 155 | ||
| 156 | export const resolveFileManagerRevealKindForConfig = <E, R>( | |
| 157 | discovery: Effect.Effect<FileManagerRevealKind | undefined, E, R>, | |
| 158 | ) => resolveDiscoveryForConfig(discovery, () => undefined); | |
| 159 | ||
| 149 | 160 | function unexpectedCompatibilityError(error: never): never { |
| 150 | 161 | throw new Error(`Unhandled compatibility error: ${String(error)}`); |
| 151 | 162 | } |
| ... | ... | @@ -1109,6 +1120,14 @@ const makeWsRpcLayer = ( |
| 1109 | 1120 | ); |
| 1110 | 1121 | const environment = yield* serverEnvironment.getDescriptor; |
| 1111 | 1122 | const auth = yield* serverAuth.getDescriptor(); |
| 1123 | const availableEditors: ReadonlyArray<EditorId> = yield* resolveAvailableEditorsForConfig( | |
| 1124 | externalLauncher.resolveAvailableEditors(), | |
| 1125 | ); | |
| 1126 | const fileManagerRevealKind = availableEditors.includes("file-manager") | |
| 1127 | ? yield* resolveFileManagerRevealKindForConfig( | |
| 1128 | externalLauncher.resolveFileManagerRevealKind(), | |
| 1129 | ) | |
| 1130 | : undefined; | |
| 1112 | 1131 | |
| 1113 | 1132 | return { |
| 1114 | 1133 | environment, |
| ... | ... | @@ -1118,9 +1137,7 @@ const makeWsRpcLayer = ( |
| 1118 | 1137 | keybindings: keybindingsConfig.keybindings, |
| 1119 | 1138 | issues: keybindingsConfig.issues, |
| 1120 | 1139 | providers, |
| 1121 | availableEditors: yield* resolveAvailableEditorsForConfig( | |
| 1122 | externalLauncher.resolveAvailableEditors(), | |
| 1123 | ), | |
| 1140 | availableEditors, | |
| 1124 | 1141 | // Same discovery-with-timeout treatment as editors: a slow probe |
| 1125 | 1142 | // must not stall server.getConfig, so it degrades to no targets. |
| 1126 | 1143 | remoteOpenTargets: yield* resolveAvailableEditorsForConfig( |
| ... | ... | @@ -1138,6 +1155,12 @@ const makeWsRpcLayer = ( |
| 1138 | 1155 | }, |
| 1139 | 1156 | settings, |
| 1140 | 1157 | shellResumeCompletionMarker: true, |
| 1158 | ...(fileManagerRevealKind === undefined | |
| 1159 | ? {} | |
| 1160 | : { | |
| 1161 | shellRevealInFileManager: true, | |
| 1162 | shellRevealInFileManagerKind: fileManagerRevealKind, | |
| 1163 | }), | |
| 1141 | 1164 | threadResumeCompletionMarker: true, |
| 1142 | 1165 | threadSnapshotPagination: true, |
| 1143 | 1166 | }; |
apps/web/src/components/ChatMarkdown.test.tsx+133-3| ... | ... | @@ -12,10 +12,15 @@ vi.mock("../state/session", async (importOriginal) => ({ |
| 12 | 12 | })); |
| 13 | 13 | vi.mock("../state/entities", () => ({ |
| 14 | 14 | readThreadShell: () => null, |
| 15 | useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), | |
| 16 | 15 | useProjects: () => [], |
| 17 | 16 | })); |
| 18 | vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); | |
| 17 | vi.mock("../remoteOpen", () => ({ | |
| 18 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), | |
| 19 | })); | |
| 20 | vi.mock("../editorPreferences", () => ({ | |
| 21 | useOpenInPreferredEditor: () => vi.fn(), | |
| 22 | usePreferredEditor: () => [null, vi.fn()], | |
| 23 | })); | |
| 19 | 24 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 20 | 25 | findProjectForChangeRequest: () => undefined, |
| 21 | 26 | matchesLinkedPullRequestUrl: () => false, |
| ... | ... | @@ -23,7 +28,124 @@ vi.mock("~/lib/openPullRequestLink", () => ({ |
| 23 | 28 | useOpenChangeRequestLink: () => vi.fn(), |
| 24 | 29 | })); |
| 25 | 30 | |
| 26 | import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown"; | |
| 31 | import ChatMarkdown, { | |
| 32 | canUseMarkdownFileShellActions, | |
| 33 | hasMarkdownFilePrimaryAction, | |
| 34 | orderedListGutterStyle, | |
| 35 | shouldUseMarkdownFileBrowserPrimaryAction, | |
| 36 | } from "./ChatMarkdown"; | |
| 37 | ||
| 38 | describe("canUseMarkdownFileShellActions", () => { | |
| 39 | const environmentId = EnvironmentId.make("environment-1"); | |
| 40 | ||
| 41 | it("allows editor and file manager actions for local environments", () => { | |
| 42 | expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); | |
| 43 | }); | |
| 44 | ||
| 45 | it("hides shell actions until the environment mode is resolved", () => { | |
| 46 | expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); | |
| 47 | }); | |
| 48 | ||
| 49 | it("hides editor and file manager actions for remote environments", () => { | |
| 50 | expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); | |
| 51 | expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); | |
| 52 | }); | |
| 53 | ||
| 54 | it("hides shell actions when no environment owns the markdown", () => { | |
| 55 | expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); | |
| 56 | }); | |
| 57 | }); | |
| 58 | ||
| 59 | describe("hasMarkdownFilePrimaryAction", () => { | |
| 60 | it("keeps the chip interactive when an editor, browser, or panel can open it", () => { | |
| 61 | expect( | |
| 62 | hasMarkdownFilePrimaryAction({ | |
| 63 | canOpenInEditor: true, | |
| 64 | canOpenInBrowser: false, | |
| 65 | canOpenInPanel: false, | |
| 66 | }), | |
| 67 | ).toBe(true); | |
| 68 | expect( | |
| 69 | hasMarkdownFilePrimaryAction({ | |
| 70 | canOpenInEditor: false, | |
| 71 | canOpenInBrowser: true, | |
| 72 | canOpenInPanel: false, | |
| 73 | }), | |
| 74 | ).toBe(true); | |
| 75 | expect( | |
| 76 | hasMarkdownFilePrimaryAction({ | |
| 77 | canOpenInEditor: false, | |
| 78 | canOpenInBrowser: false, | |
| 79 | canOpenInPanel: true, | |
| 80 | }), | |
| 81 | ).toBe(true); | |
| 82 | }); | |
| 83 | ||
| 84 | it("removes the link affordance when no primary action can open the file", () => { | |
| 85 | expect( | |
| 86 | hasMarkdownFilePrimaryAction({ | |
| 87 | canOpenInEditor: false, | |
| 88 | canOpenInBrowser: false, | |
| 89 | canOpenInPanel: false, | |
| 90 | }), | |
| 91 | ).toBe(false); | |
| 92 | }); | |
| 93 | }); | |
| 94 | ||
| 95 | describe("ChatMarkdown file option chips", () => { | |
| 96 | it("keeps the fallback button text selectable", () => { | |
| 97 | const html = renderToStaticMarkup( | |
| 98 | <ChatMarkdown cwd="/tmp/project" text="[Source](/tmp/project/src/main.ts)" />, | |
| 99 | ); | |
| 100 | ||
| 101 | expect(html).toContain("<button"); | |
| 102 | expect(html).toContain('aria-haspopup="menu"'); | |
| 103 | expect(html).toContain("select-text"); | |
| 104 | }); | |
| 105 | }); | |
| 106 | ||
| 107 | describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { | |
| 108 | it("uses the browser when it is the only available primary action", () => { | |
| 109 | expect( | |
| 110 | shouldUseMarkdownFileBrowserPrimaryAction({ | |
| 111 | iconPath: "/tmp/report.html", | |
| 112 | canOpenInEditor: false, | |
| 113 | canOpenInBrowser: true, | |
| 114 | canOpenInPanel: false, | |
| 115 | }), | |
| 116 | ).toBe(true); | |
| 117 | }); | |
| 118 | ||
| 119 | it("preserves the normal editor and panel defaults for HTML files", () => { | |
| 120 | expect( | |
| 121 | shouldUseMarkdownFileBrowserPrimaryAction({ | |
| 122 | iconPath: "/tmp/report.html", | |
| 123 | canOpenInEditor: true, | |
| 124 | canOpenInBrowser: true, | |
| 125 | canOpenInPanel: false, | |
| 126 | }), | |
| 127 | ).toBe(false); | |
| 128 | expect( | |
| 129 | shouldUseMarkdownFileBrowserPrimaryAction({ | |
| 130 | iconPath: "/tmp/report.html", | |
| 131 | canOpenInEditor: false, | |
| 132 | canOpenInBrowser: true, | |
| 133 | canOpenInPanel: true, | |
| 134 | }), | |
| 135 | ).toBe(false); | |
| 136 | }); | |
| 137 | ||
| 138 | it("continues to open PDF files in the browser by default", () => { | |
| 139 | expect( | |
| 140 | shouldUseMarkdownFileBrowserPrimaryAction({ | |
| 141 | iconPath: "/tmp/report.pdf", | |
| 142 | canOpenInEditor: true, | |
| 143 | canOpenInBrowser: true, | |
| 144 | canOpenInPanel: true, | |
| 145 | }), | |
| 146 | ).toBe(true); | |
| 147 | }); | |
| 148 | }); | |
| 27 | 149 | |
| 28 | 150 | describe("orderedListGutterStyle", () => { |
| 29 | 151 | it("leaves the default gutter alone for single-digit lists", () => { |
| ... | ... | @@ -67,10 +189,13 @@ describe("orderedListGutterStyle", () => { |
| 67 | 189 | }); |
| 68 | 190 | |
| 69 | 191 | describe("ChatMarkdown Windows file links", () => { |
| 192 | const environmentId = EnvironmentId.make("env-windows"); | |
| 193 | ||
| 70 | 194 | it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { |
| 71 | 195 | const html = renderToStaticMarkup( |
| 72 | 196 | <ChatMarkdown |
| 73 | 197 | cwd="C:/Users/shawn/project" |
| 198 | environmentId={environmentId} | |
| 74 | 199 | text="[Open](C:/Users/shawn/project/src/main.ts)" |
| 75 | 200 | lineBreaks={!parseRawHtml} |
| 76 | 201 | parseRawHtml={parseRawHtml} |
| ... | ... | @@ -85,6 +210,7 @@ describe("ChatMarkdown Windows file links", () => { |
| 85 | 210 | const html = renderToStaticMarkup( |
| 86 | 211 | <ChatMarkdown |
| 87 | 212 | cwd="C:/Users/shawn/project" |
| 213 | environmentId={environmentId} | |
| 88 | 214 | text={String.raw`[Open](C:\Users\shawn\project\src\main.ts)`} |
| 89 | 215 | lineBreaks={!parseRawHtml} |
| 90 | 216 | parseRawHtml={parseRawHtml} |
| ... | ... | @@ -101,6 +227,7 @@ describe("ChatMarkdown Windows file links", () => { |
| 101 | 227 | const html = renderToStaticMarkup( |
| 102 | 228 | <ChatMarkdown |
| 103 | 229 | cwd="C:/Users/shawn/project" |
| 230 | environmentId={environmentId} | |
| 104 | 231 | text={String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`} |
| 105 | 232 | lineBreaks={!parseRawHtml} |
| 106 | 233 | parseRawHtml={parseRawHtml} |
| ... | ... | @@ -119,6 +246,7 @@ describe("ChatMarkdown Windows file links", () => { |
| 119 | 246 | const html = renderToStaticMarkup( |
| 120 | 247 | <ChatMarkdown |
| 121 | 248 | cwd="C:/Users/shawn/project" |
| 249 | environmentId={environmentId} | |
| 122 | 250 | text={`[Source](${path}) and \`${path}\``} |
| 123 | 251 | lineBreaks={!parseRawHtml} |
| 124 | 252 | parseRawHtml={parseRawHtml} |
| ... | ... | @@ -134,6 +262,7 @@ describe("ChatMarkdown Windows file links", () => { |
| 134 | 262 | const html = renderToStaticMarkup( |
| 135 | 263 | <ChatMarkdown |
| 136 | 264 | cwd="C:/Users/shawn/project" |
| 265 | environmentId={environmentId} | |
| 137 | 266 | text={"[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"} |
| 138 | 267 | lineBreaks={!parseRawHtml} |
| 139 | 268 | parseRawHtml={parseRawHtml} |
| ... | ... | @@ -148,6 +277,7 @@ describe("ChatMarkdown Windows file links", () => { |
| 148 | 277 | const html = renderToStaticMarkup( |
| 149 | 278 | <ChatMarkdown |
| 150 | 279 | cwd="C:/Users/shawn/project" |
| 280 | environmentId={environmentId} | |
| 151 | 281 | text="[unsafe](javascript:alert(1)) and [unknown](d:alert(1))" |
| 152 | 282 | lineBreaks={!parseRawHtml} |
| 153 | 283 | parseRawHtml={parseRawHtml} |
apps/web/src/components/ChatMarkdown.tsx+282-54| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | WrapTextIcon, |
| 15 | 15 | } from "lucide-react"; |
| 16 | 16 | import type { |
| 17 | EnvironmentId, | |
| 17 | 18 | ScopedThreadRef, |
| 18 | 19 | ServerProviderSkill, |
| 19 | 20 | ThreadLinkedPullRequest, |
| ... | ... | @@ -52,6 +53,10 @@ import { remarkGithubAlerts } from "../markdown-github-alerts"; |
| 52 | 53 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 53 | 54 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; |
| 54 | 55 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 56 | import { | |
| 57 | revealInFileExplorerLabelForKind, | |
| 58 | revealInFileExplorerLabelForOs, | |
| 59 | } from "./preview/fileExplorerLabel"; | |
| 55 | 60 | import { |
| 56 | 61 | resolveExternalWebLinkHost, |
| 57 | 62 | showExternalLinkContextMenu, |
| ... | ... | @@ -64,7 +69,12 @@ import { ScrollArea } from "./ui/scroll-area"; |
| 64 | 69 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; |
| 65 | 70 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 66 | 71 | import { recordVisitForThread } from "../browserHistoryStore"; |
| 67 | import { useOpenInPreferredEditor } from "../editorPreferences"; | |
| 72 | import { | |
| 73 | PreferredEditorEnvironmentRequiredError, | |
| 74 | useOpenInPreferredEditor, | |
| 75 | usePreferredEditor, | |
| 76 | } from "../editorPreferences"; | |
| 77 | import { openInEditorMenuLabel } from "../editorLabels"; | |
| 68 | 78 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 69 | 79 | import { fnv1a32 } from "../lib/diffRendering"; |
| 70 | 80 | import { LRUCache } from "../lib/lruCache"; |
| ... | ... | @@ -91,9 +101,11 @@ import { |
| 91 | 101 | import { readLocalApi } from "../localApi"; |
| 92 | 102 | import { useAssetUrlState } from "../assets/assetUrls"; |
| 93 | 103 | import { cn } from "../lib/utils"; |
| 104 | import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; | |
| 94 | 105 | import { useRightPanelStore } from "../rightPanelStore"; |
| 95 | import { readThreadShell, useActiveEnvironmentId, useProjects } from "../state/entities"; | |
| 106 | import { readThreadShell, useProjects } from "../state/entities"; | |
| 96 | 107 | import { serverEnvironment } from "../state/server"; |
| 108 | import { shellEnvironment } from "../state/shell"; | |
| 97 | 109 | import { assetEnvironment } from "../state/assets"; |
| 98 | 110 | import { usePreparedConnection } from "../state/session"; |
| 99 | 111 | import { previewEnvironment } from "../state/preview"; |
| ... | ... | @@ -115,6 +127,7 @@ import { |
| 115 | 127 | } from "~/lib/openPullRequestLink"; |
| 116 | 128 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 117 | 129 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; |
| 130 | import { resolvePathLinkTarget } from "../terminal-links"; | |
| 118 | 131 | import { |
| 119 | 132 | isBrowserPreviewFile, |
| 120 | 133 | openFileInPreview, |
| ... | ... | @@ -126,6 +139,8 @@ interface ChatMarkdownProps { |
| 126 | 139 | text: string; |
| 127 | 140 | cwd: string | undefined; |
| 128 | 141 | threadRef?: ScopedThreadRef | undefined; |
| 142 | /** Environment that owns non-thread markdown, such as a pull request panel. */ | |
| 143 | environmentId?: EnvironmentId | undefined; | |
| 129 | 144 | onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; |
| 130 | 145 | isStreaming?: boolean; |
| 131 | 146 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| ... | ... | @@ -136,6 +151,35 @@ interface ChatMarkdownProps { |
| 136 | 151 | parseRawHtml?: boolean; |
| 137 | 152 | } |
| 138 | 153 | |
| 154 | export function canUseMarkdownFileShellActions( | |
| 155 | environmentId: EnvironmentId | null, | |
| 156 | remoteOpenMode: RemoteOpenMode, | |
| 157 | isRemoteOpenResolved: boolean, | |
| 158 | ): boolean { | |
| 159 | return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; | |
| 160 | } | |
| 161 | ||
| 162 | export function hasMarkdownFilePrimaryAction(input: { | |
| 163 | canOpenInEditor: boolean; | |
| 164 | canOpenInBrowser: boolean; | |
| 165 | canOpenInPanel: boolean; | |
| 166 | }): boolean { | |
| 167 | return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; | |
| 168 | } | |
| 169 | ||
| 170 | export function shouldUseMarkdownFileBrowserPrimaryAction(input: { | |
| 171 | iconPath: string; | |
| 172 | canOpenInEditor: boolean; | |
| 173 | canOpenInBrowser: boolean; | |
| 174 | canOpenInPanel: boolean; | |
| 175 | }): boolean { | |
| 176 | return ( | |
| 177 | input.canOpenInBrowser && | |
| 178 | (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || | |
| 179 | (!input.canOpenInEditor && !input.canOpenInPanel)) | |
| 180 | ); | |
| 181 | } | |
| 182 | ||
| 139 | 183 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 140 | 184 | |
| 141 | 185 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; |
| ... | ... | @@ -875,14 +919,19 @@ interface MarkdownFileLinkProps { |
| 875 | 919 | copyMarkdown: string; |
| 876 | 920 | theme: "light" | "dark"; |
| 877 | 921 | threadRef?: ScopedThreadRef | undefined; |
| 878 | onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>; | |
| 922 | onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined; | |
| 879 | 923 | onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; |
| 924 | openInEditorMenuLabel: string; | |
| 880 | 925 | onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined; |
| 926 | onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined; | |
| 927 | /** Platform-specific menu label ("Reveal in Finder", ...); required for the | |
| 928 | reveal item to show. */ | |
| 929 | revealLabel?: string | undefined; | |
| 881 | 930 | className?: string | undefined; |
| 882 | 931 | } |
| 883 | 932 | |
| 884 | const MARKDOWN_FILE_LINK_CLASS_NAME = | |
| 885 | "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; | |
| 933 | const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; | |
| 934 | const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70`; | |
| 886 | 935 | |
| 887 | 936 | function pathParentSegments(path: string): string[] { |
| 888 | 937 | const normalized = path.replaceAll("\\", "/"); |
| ... | ... | @@ -1229,10 +1278,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1229 | 1278 | threadRef, |
| 1230 | 1279 | onOpen, |
| 1231 | 1280 | onOpenInPanel, |
| 1281 | openInEditorMenuLabel, | |
| 1232 | 1282 | onOpenInBrowser, |
| 1283 | onReveal, | |
| 1284 | revealLabel, | |
| 1233 | 1285 | className, |
| 1234 | 1286 | }: MarkdownFileLinkProps) { |
| 1235 | 1287 | const handleOpenInEditor = useCallback(() => { |
| 1288 | if (!onOpen) { | |
| 1289 | return; | |
| 1290 | } | |
| 1236 | 1291 | void (async () => { |
| 1237 | 1292 | try { |
| 1238 | 1293 | const result = await onOpen(targetPath); |
| ... | ... | @@ -1313,6 +1368,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1313 | 1368 | })(); |
| 1314 | 1369 | }, [onOpenInBrowser, targetPath]); |
| 1315 | 1370 | |
| 1371 | const handleRevealInFileManager = useCallback(() => { | |
| 1372 | if (!onReveal) { | |
| 1373 | return; | |
| 1374 | } | |
| 1375 | void (async () => { | |
| 1376 | try { | |
| 1377 | const result = await onReveal(); | |
| 1378 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | |
| 1379 | return; | |
| 1380 | } | |
| 1381 | reportMarkdownActionFailure( | |
| 1382 | { operation: "reveal-file-in-file-manager", target: targetPath }, | |
| 1383 | result.cause, | |
| 1384 | ); | |
| 1385 | const error = squashAtomCommandFailure(result); | |
| 1386 | toastManager.add( | |
| 1387 | stackedThreadToast({ | |
| 1388 | type: "error", | |
| 1389 | title: "Unable to reveal file", | |
| 1390 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 1391 | }), | |
| 1392 | ); | |
| 1393 | } catch (cause) { | |
| 1394 | reportMarkdownActionFailure( | |
| 1395 | { operation: "reveal-file-in-file-manager", target: targetPath }, | |
| 1396 | cause, | |
| 1397 | ); | |
| 1398 | toastManager.add( | |
| 1399 | stackedThreadToast({ | |
| 1400 | type: "error", | |
| 1401 | title: "Unable to reveal file", | |
| 1402 | description: cause instanceof Error ? cause.message : "An error occurred.", | |
| 1403 | }), | |
| 1404 | ); | |
| 1405 | } | |
| 1406 | })(); | |
| 1407 | }, [onReveal, targetPath]); | |
| 1408 | ||
| 1316 | 1409 | const handleCopy = useCallback( |
| 1317 | 1410 | (value: string, title: string) => { |
| 1318 | 1411 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { |
| ... | ... | @@ -1352,25 +1445,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1352 | 1445 | [targetPath], |
| 1353 | 1446 | ); |
| 1354 | 1447 | |
| 1355 | const handleContextMenu = useCallback( | |
| 1356 | async (event: ReactMouseEvent<HTMLAnchorElement>) => { | |
| 1357 | event.preventDefault(); | |
| 1358 | event.stopPropagation(); | |
| 1359 | ||
| 1448 | const showFileContextMenu = useCallback( | |
| 1449 | async (position: { x: number; y: number }) => { | |
| 1360 | 1450 | const api = readLocalApi(); |
| 1361 | 1451 | if (!api) return; |
| 1362 | 1452 | |
| 1363 | 1453 | try { |
| 1364 | 1454 | const clicked = await api.contextMenu.show( |
| 1365 | 1455 | [ |
| 1366 | { id: "open", label: "Open in editor" }, | |
| 1456 | ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), | |
| 1367 | 1457 | ...(onOpenInBrowser |
| 1368 | 1458 | ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) |
| 1369 | 1459 | : []), |
| 1460 | ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), | |
| 1370 | 1461 | { id: "copy-relative", label: "Copy relative path" }, |
| 1371 | 1462 | { id: "copy-full", label: "Copy full path" }, |
| 1372 | 1463 | ] as const, |
| 1373 | { x: event.clientX, y: event.clientY }, | |
| 1464 | position, | |
| 1374 | 1465 | ); |
| 1375 | 1466 | |
| 1376 | 1467 | if (clicked === "open") { |
| ... | ... | @@ -1381,6 +1472,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1381 | 1472 | handleOpenInBrowser(); |
| 1382 | 1473 | return; |
| 1383 | 1474 | } |
| 1475 | if (clicked === "reveal") { | |
| 1476 | handleRevealInFileManager(); | |
| 1477 | return; | |
| 1478 | } | |
| 1384 | 1479 | if (clicked === "copy-relative") { |
| 1385 | 1480 | handleCopy(displayPath, "Relative path"); |
| 1386 | 1481 | return; |
| ... | ... | @@ -1395,34 +1490,100 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ |
| 1395 | 1490 | ); |
| 1396 | 1491 | } |
| 1397 | 1492 | }, |
| 1398 | [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], | |
| 1493 | [ | |
| 1494 | displayPath, | |
| 1495 | handleCopy, | |
| 1496 | handleOpenInBrowser, | |
| 1497 | handleOpenInEditor, | |
| 1498 | handleRevealInFileManager, | |
| 1499 | onOpenInBrowser, | |
| 1500 | onOpen, | |
| 1501 | onReveal, | |
| 1502 | openInEditorMenuLabel, | |
| 1503 | revealLabel, | |
| 1504 | targetPath, | |
| 1505 | ], | |
| 1399 | 1506 | ); |
| 1400 | 1507 | |
| 1508 | const handleContextMenu = useCallback( | |
| 1509 | (event: ReactMouseEvent<HTMLElement>) => { | |
| 1510 | event.preventDefault(); | |
| 1511 | event.stopPropagation(); | |
| 1512 | const position = | |
| 1513 | event.clientX === 0 && event.clientY === 0 | |
| 1514 | ? (() => { | |
| 1515 | const bounds = event.currentTarget.getBoundingClientRect(); | |
| 1516 | return { x: bounds.left, y: bounds.bottom }; | |
| 1517 | })() | |
| 1518 | : { x: event.clientX, y: event.clientY }; | |
| 1519 | void showFileContextMenu(position); | |
| 1520 | }, | |
| 1521 | [showFileContextMenu], | |
| 1522 | ); | |
| 1523 | ||
| 1524 | const canOpenInEditor = onOpen !== undefined; | |
| 1525 | const canOpenInBrowser = onOpenInBrowser !== undefined; | |
| 1526 | const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); | |
| 1527 | const hasPrimaryAction = hasMarkdownFilePrimaryAction({ | |
| 1528 | canOpenInEditor, | |
| 1529 | canOpenInBrowser, | |
| 1530 | canOpenInPanel, | |
| 1531 | }); | |
| 1532 | const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ | |
| 1533 | iconPath, | |
| 1534 | canOpenInEditor, | |
| 1535 | canOpenInBrowser, | |
| 1536 | canOpenInPanel, | |
| 1537 | }); | |
| 1538 | ||
| 1401 | 1539 | return ( |
| 1402 | 1540 | <Tooltip> |
| 1403 | 1541 | <TooltipTrigger |
| 1404 | 1542 | render={ |
| 1405 | <a | |
| 1406 | href={href} | |
| 1407 | className={cn(CHAT_FILE_TAG_CHIP_CLASS_NAME, MARKDOWN_FILE_LINK_CLASS_NAME, className)} | |
| 1408 | data-markdown-copy={copyMarkdown} | |
| 1409 | onClick={(event) => { | |
| 1410 | event.preventDefault(); | |
| 1411 | event.stopPropagation(); | |
| 1412 | if (shouldOpenMarkdownFileLinkInEditor(event)) { | |
| 1413 | handleOpenInEditor(); | |
| 1414 | return; | |
| 1415 | } | |
| 1416 | if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) { | |
| 1417 | handleOpenInBrowser(); | |
| 1418 | return; | |
| 1419 | } | |
| 1420 | handleOpenInFilePreview(); | |
| 1421 | }} | |
| 1422 | onContextMenu={handleContextMenu} | |
| 1423 | > | |
| 1424 | <FileTagChipContent path={iconPath} label={label} theme={theme} selectable /> | |
| 1425 | </a> | |
| 1543 | hasPrimaryAction ? ( | |
| 1544 | <a | |
| 1545 | href={href} | |
| 1546 | className={cn( | |
| 1547 | CHAT_FILE_TAG_CHIP_CLASS_NAME, | |
| 1548 | MARKDOWN_FILE_LINK_CLASS_NAME, | |
| 1549 | className, | |
| 1550 | )} | |
| 1551 | data-markdown-copy={copyMarkdown} | |
| 1552 | onClick={(event) => { | |
| 1553 | event.preventDefault(); | |
| 1554 | event.stopPropagation(); | |
| 1555 | if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { | |
| 1556 | handleOpenInEditor(); | |
| 1557 | return; | |
| 1558 | } | |
| 1559 | if (useBrowserPrimaryAction) { | |
| 1560 | handleOpenInBrowser(); | |
| 1561 | return; | |
| 1562 | } | |
| 1563 | handleOpenInFilePreview(); | |
| 1564 | }} | |
| 1565 | onContextMenu={handleContextMenu} | |
| 1566 | > | |
| 1567 | <FileTagChipContent path={iconPath} label={label} theme={theme} selectable /> | |
| 1568 | </a> | |
| 1569 | ) : ( | |
| 1570 | <button | |
| 1571 | type="button" | |
| 1572 | aria-label={`File options for ${label}`} | |
| 1573 | aria-haspopup="menu" | |
| 1574 | className={cn( | |
| 1575 | CHAT_FILE_TAG_CHIP_CLASS_NAME, | |
| 1576 | MARKDOWN_FILE_LINK_CLASS_NAME, | |
| 1577 | "select-text", | |
| 1578 | className, | |
| 1579 | )} | |
| 1580 | data-markdown-copy={copyMarkdown} | |
| 1581 | onClick={handleContextMenu} | |
| 1582 | onContextMenu={handleContextMenu} | |
| 1583 | > | |
| 1584 | <FileTagChipContent path={iconPath} label={label} theme={theme} selectable /> | |
| 1585 | </button> | |
| 1586 | ) | |
| 1426 | 1587 | } |
| 1427 | 1588 | /> |
| 1428 | 1589 | <TooltipPopup |
| ... | ... | @@ -1456,7 +1617,10 @@ function areMarkdownFileLinkPropsEqual( |
| 1456 | 1617 | previous.threadRef === next.threadRef && |
| 1457 | 1618 | previous.onOpen === next.onOpen && |
| 1458 | 1619 | previous.onOpenInPanel === next.onOpenInPanel && |
| 1620 | previous.openInEditorMenuLabel === next.openInEditorMenuLabel && | |
| 1459 | 1621 | previous.onOpenInBrowser === next.onOpenInBrowser && |
| 1622 | previous.onReveal === next.onReveal && | |
| 1623 | previous.revealLabel === next.revealLabel && | |
| 1460 | 1624 | previous.className === next.className |
| 1461 | 1625 | ); |
| 1462 | 1626 | } |
| ... | ... | @@ -1465,6 +1629,7 @@ function ChatMarkdown({ |
| 1465 | 1629 | text, |
| 1466 | 1630 | cwd, |
| 1467 | 1631 | threadRef, |
| 1632 | environmentId: explicitEnvironmentId, | |
| 1468 | 1633 | onTaskListChange, |
| 1469 | 1634 | isStreaming = false, |
| 1470 | 1635 | skills = EMPTY_MARKDOWN_SKILLS, |
| ... | ... | @@ -1485,16 +1650,49 @@ function ChatMarkdown({ |
| 1485 | 1650 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 1486 | 1651 | reportFailure: false, |
| 1487 | 1652 | }); |
| 1488 | const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); | |
| 1489 | const environmentId = useActiveEnvironmentId(); | |
| 1653 | const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; | |
| 1654 | const remoteOpen = useRemoteOpenResolution(environmentId); | |
| 1655 | const canUseShellActions = canUseMarkdownFileShellActions( | |
| 1656 | environmentId, | |
| 1657 | remoteOpen.state.mode, | |
| 1658 | remoteOpen.isResolved, | |
| 1659 | ); | |
| 1660 | const preparedConnection = usePreparedConnection(environmentId); | |
| 1490 | 1661 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 1491 | 1662 | const threadServerConfig = useAtomValue( |
| 1492 | 1663 | serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), |
| 1493 | 1664 | ); |
| 1494 | 1665 | const projects = useProjects(); |
| 1495 | const openInPreferredEditor = useOpenInPreferredEditor( | |
| 1496 | environmentId, | |
| 1497 | serverConfig?.availableEditors ?? [], | |
| 1666 | const availableEditors = serverConfig?.availableEditors ?? []; | |
| 1667 | const [preferredEditor] = usePreferredEditor(availableEditors); | |
| 1668 | const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); | |
| 1669 | const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); | |
| 1670 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { | |
| 1671 | reportFailure: false, | |
| 1672 | }); | |
| 1673 | const revealInFileManagerLabel = | |
| 1674 | environmentId !== null && | |
| 1675 | serverConfig?.shellRevealInFileManager === true && | |
| 1676 | serverConfig.availableEditors.includes("file-manager") | |
| 1677 | ? serverConfig.shellRevealInFileManagerKind === undefined | |
| 1678 | ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) | |
| 1679 | : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) | |
| 1680 | : undefined; | |
| 1681 | const revealFileInFileManager = useCallback( | |
| 1682 | (filePath: string) => { | |
| 1683 | if (environmentId === null) { | |
| 1684 | return Promise.resolve( | |
| 1685 | AsyncResult.failure<void, PreferredEditorEnvironmentRequiredError>( | |
| 1686 | Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), | |
| 1687 | ), | |
| 1688 | ); | |
| 1689 | } | |
| 1690 | return openInEditor({ | |
| 1691 | environmentId, | |
| 1692 | input: { cwd: filePath, editor: "file-manager", reveal: true }, | |
| 1693 | }); | |
| 1694 | }, | |
| 1695 | [environmentId, openInEditor], | |
| 1498 | 1696 | ); |
| 1499 | 1697 | const diffThemeName = resolveDiffThemeName(resolvedTheme); |
| 1500 | 1698 | const markdownFileLinkMetaByHref = useMemo(() => { |
| ... | ... | @@ -1636,6 +1834,26 @@ function ChatMarkdown({ |
| 1636 | 1834 | }, |
| 1637 | 1835 | [createAssetUrl, openPreview, preparedConnection, threadRef], |
| 1638 | 1836 | ); |
| 1837 | const findWorkspaceBasenameMatch = useCallback( | |
| 1838 | async (workspaceRelativePath: string) => { | |
| 1839 | if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { | |
| 1840 | return null; | |
| 1841 | } | |
| 1842 | const result = await searchProjectEntries({ | |
| 1843 | environmentId, | |
| 1844 | input: { | |
| 1845 | cwd, | |
| 1846 | query: workspaceRelativePath, | |
| 1847 | limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, | |
| 1848 | kind: "file", | |
| 1849 | }, | |
| 1850 | }); | |
| 1851 | return result._tag === "Success" | |
| 1852 | ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) | |
| 1853 | : null; | |
| 1854 | }, | |
| 1855 | [cwd, environmentId, searchProjectEntries], | |
| 1856 | ); | |
| 1639 | 1857 | // A bare filename resolves to the workspace root, which is rarely where the |
| 1640 | 1858 | // file is, so ask the index before opening. |
| 1641 | 1859 | const openFileInPanel = useCallback( |
| ... | ... | @@ -1651,24 +1869,23 @@ function ChatMarkdown({ |
| 1651 | 1869 | return; |
| 1652 | 1870 | } |
| 1653 | 1871 | void (async () => { |
| 1654 | const result = await searchProjectEntries({ | |
| 1655 | environmentId: threadRef.environmentId, | |
| 1656 | input: { | |
| 1657 | cwd, | |
| 1658 | query: workspaceRelativePath, | |
| 1659 | limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, | |
| 1660 | kind: "file", | |
| 1661 | }, | |
| 1662 | }); | |
| 1663 | const match = | |
| 1664 | result._tag === "Success" | |
| 1665 | ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) | |
| 1666 | : null; | |
| 1872 | const match = await findWorkspaceBasenameMatch(workspaceRelativePath); | |
| 1667 | 1873 | if (!isLatestLookup()) return; |
| 1668 | 1874 | openAt(match ?? workspaceRelativePath); |
| 1669 | 1875 | })(); |
| 1670 | 1876 | }, |
| 1671 | [cwd, searchProjectEntries, threadRef], | |
| 1877 | [cwd, findWorkspaceBasenameMatch, threadRef], | |
| 1878 | ); | |
| 1879 | const revealMarkdownFileInFileManager = useCallback( | |
| 1880 | async (fileLinkMeta: MarkdownFileLinkMeta) => { | |
| 1881 | const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; | |
| 1882 | const match = workspaceRelativePath | |
| 1883 | ? await findWorkspaceBasenameMatch(workspaceRelativePath) | |
| 1884 | : null; | |
| 1885 | const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; | |
| 1886 | return revealFileInFileManager(filePath); | |
| 1887 | }, | |
| 1888 | [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], | |
| 1672 | 1889 | ); |
| 1673 | 1890 | /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component |
| 1674 | 1891 | * renderers that close over this message's metadata. useMemo keeps them stable until that |
| ... | ... | @@ -1704,8 +1921,15 @@ function ChatMarkdown({ |
| 1704 | 1921 | copyMarkdown={copyMarkdown} |
| 1705 | 1922 | theme={resolvedTheme} |
| 1706 | 1923 | threadRef={threadRef} |
| 1707 | onOpen={openInPreferredEditor} | |
| 1924 | {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} | |
| 1708 | 1925 | onOpenInPanel={openFileInPanel} |
| 1926 | openInEditorMenuLabel={preferredEditorMenuLabel} | |
| 1927 | onReveal={ | |
| 1928 | canUseShellActions && revealInFileManagerLabel !== undefined | |
| 1929 | ? () => revealMarkdownFileInFileManager(fileLinkMeta) | |
| 1930 | : undefined | |
| 1931 | } | |
| 1932 | revealLabel={revealInFileManagerLabel} | |
| 1709 | 1933 | onOpenInBrowser={ |
| 1710 | 1934 | threadRef && |
| 1711 | 1935 | isPreviewSupportedInRuntime() && |
| ... | ... | @@ -1984,6 +2208,7 @@ function ChatMarkdown({ |
| 1984 | 2208 | }, |
| 1985 | 2209 | }; |
| 1986 | 2210 | }, [ |
| 2211 | canUseShellActions, | |
| 1987 | 2212 | cwd, |
| 1988 | 2213 | diffThemeName, |
| 1989 | 2214 | fileLinkParentSuffixByPath, |
| ... | ... | @@ -1996,8 +2221,11 @@ function ChatMarkdown({ |
| 1996 | 2221 | openChangeRequestLink, |
| 1997 | 2222 | openExternalLinkInPreview, |
| 1998 | 2223 | openMarkdownFileInPreview, |
| 2224 | preferredEditorMenuLabel, | |
| 1999 | 2225 | resolveThreadPullRequest, |
| 2000 | 2226 | resolvedTheme, |
| 2227 | revealMarkdownFileInFileManager, | |
| 2228 | revealInFileManagerLabel, | |
| 2001 | 2229 | skills, |
| 2002 | 2230 | text, |
| 2003 | 2231 | threadRef, |
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+7-2| ... | ... | @@ -25,10 +25,15 @@ vi.mock("../state/session", async (importOriginal) => ({ |
| 25 | 25 | })); |
| 26 | 26 | vi.mock("../state/entities", () => ({ |
| 27 | 27 | readThreadShell: () => null, |
| 28 | useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), | |
| 29 | 28 | useProjects: () => [], |
| 30 | 29 | })); |
| 31 | vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); | |
| 30 | vi.mock("../remoteOpen", () => ({ | |
| 31 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), | |
| 32 | })); | |
| 33 | vi.mock("../editorPreferences", () => ({ | |
| 34 | useOpenInPreferredEditor: () => vi.fn(), | |
| 35 | usePreferredEditor: () => [null, vi.fn()], | |
| 36 | })); | |
| 32 | 37 | vi.mock("~/lib/openPullRequestLink", () => ({ |
| 33 | 38 | findProjectForChangeRequest: () => undefined, |
| 34 | 39 | matchesLinkedPullRequestUrl: () => false, |
apps/web/src/components/chat/OpenInPicker.tsx+6-28| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | import { memo, useCallback, useEffect, useMemo } from "react"; |
| 8 | 8 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; |
| 9 | 9 | import { usePreferredEditor } from "../../editorPreferences"; |
| 10 | import { editorLabelForPlatform } from "../../editorLabels"; | |
| 10 | 11 | import { |
| 11 | 12 | openRemoteEditorUrl, |
| 12 | 13 | useRemoteCapableEditors, |
| ... | ... | @@ -43,7 +44,7 @@ import { |
| 43 | 44 | RustRoverIcon, |
| 44 | 45 | WebStormIcon, |
| 45 | 46 | } from "../JetBrainsIcons"; |
| 46 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | |
| 47 | import { cn } from "~/lib/utils"; | |
| 47 | 48 | import { shellEnvironment } from "~/state/shell"; |
| 48 | 49 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 49 | 50 | |
| ... | ... | @@ -55,140 +56,117 @@ type OpenInOption = { |
| 55 | 56 | }; |
| 56 | 57 | |
| 57 | 58 | const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => { |
| 58 | const baseOptions: ReadonlyArray<OpenInOption> = [ | |
| 59 | const baseOptions: ReadonlyArray<Omit<OpenInOption, "label">> = [ | |
| 59 | 60 | { |
| 60 | label: "Cursor", | |
| 61 | 61 | Icon: CursorIcon, |
| 62 | 62 | value: "cursor", |
| 63 | 63 | kind: "brand", |
| 64 | 64 | }, |
| 65 | 65 | { |
| 66 | label: "Trae", | |
| 67 | 66 | Icon: TraeIcon, |
| 68 | 67 | value: "trae", |
| 69 | 68 | kind: "brand", |
| 70 | 69 | }, |
| 71 | 70 | { |
| 72 | label: "Kiro", | |
| 73 | 71 | Icon: KiroIcon, |
| 74 | 72 | value: "kiro", |
| 75 | 73 | kind: "brand", |
| 76 | 74 | }, |
| 77 | 75 | { |
| 78 | label: "VS Code", | |
| 79 | 76 | Icon: VisualStudioCode, |
| 80 | 77 | value: "vscode", |
| 81 | 78 | kind: "brand", |
| 82 | 79 | }, |
| 83 | 80 | { |
| 84 | label: "VS Code Insiders", | |
| 85 | 81 | Icon: VisualStudioCodeInsiders, |
| 86 | 82 | value: "vscode-insiders", |
| 87 | 83 | kind: "brand", |
| 88 | 84 | }, |
| 89 | 85 | { |
| 90 | label: "VSCodium", | |
| 91 | 86 | Icon: VSCodium, |
| 92 | 87 | value: "vscodium", |
| 93 | 88 | kind: "brand", |
| 94 | 89 | }, |
| 95 | 90 | { |
| 96 | label: "Zed", | |
| 97 | 91 | Icon: Zed, |
| 98 | 92 | value: "zed", |
| 99 | 93 | kind: "brand", |
| 100 | 94 | }, |
| 101 | 95 | { |
| 102 | label: "Antigravity", | |
| 103 | 96 | Icon: AntigravityIcon, |
| 104 | 97 | value: "antigravity", |
| 105 | 98 | kind: "brand", |
| 106 | 99 | }, |
| 107 | 100 | { |
| 108 | label: "IntelliJ IDEA", | |
| 109 | 101 | Icon: IntelliJIdeaIcon, |
| 110 | 102 | value: "idea", |
| 111 | 103 | kind: "brand", |
| 112 | 104 | }, |
| 113 | 105 | { |
| 114 | label: "Aqua", | |
| 115 | 106 | Icon: AquaIcon, |
| 116 | 107 | value: "aqua", |
| 117 | 108 | kind: "brand", |
| 118 | 109 | }, |
| 119 | 110 | { |
| 120 | label: "CLion", | |
| 121 | 111 | Icon: CLionIcon, |
| 122 | 112 | value: "clion", |
| 123 | 113 | kind: "brand", |
| 124 | 114 | }, |
| 125 | 115 | { |
| 126 | label: "DataGrip", | |
| 127 | 116 | Icon: DataGripIcon, |
| 128 | 117 | value: "datagrip", |
| 129 | 118 | kind: "brand", |
| 130 | 119 | }, |
| 131 | 120 | { |
| 132 | label: "DataSpell", | |
| 133 | 121 | Icon: DataSpellIcon, |
| 134 | 122 | value: "dataspell", |
| 135 | 123 | kind: "brand", |
| 136 | 124 | }, |
| 137 | 125 | { |
| 138 | label: "GoLand", | |
| 139 | 126 | Icon: GoLandIcon, |
| 140 | 127 | value: "goland", |
| 141 | 128 | kind: "brand", |
| 142 | 129 | }, |
| 143 | 130 | { |
| 144 | label: "PhpStorm", | |
| 145 | 131 | Icon: PhpStormIcon, |
| 146 | 132 | value: "phpstorm", |
| 147 | 133 | kind: "brand", |
| 148 | 134 | }, |
| 149 | 135 | { |
| 150 | label: "PyCharm", | |
| 151 | 136 | Icon: PyCharmIcon, |
| 152 | 137 | value: "pycharm", |
| 153 | 138 | kind: "brand", |
| 154 | 139 | }, |
| 155 | 140 | { |
| 156 | label: "Rider", | |
| 157 | 141 | Icon: RiderIcon, |
| 158 | 142 | value: "rider", |
| 159 | 143 | kind: "brand", |
| 160 | 144 | }, |
| 161 | 145 | { |
| 162 | label: "RubyMine", | |
| 163 | 146 | Icon: RubyMineIcon, |
| 164 | 147 | value: "rubymine", |
| 165 | 148 | kind: "brand", |
| 166 | 149 | }, |
| 167 | 150 | { |
| 168 | label: "RustRover", | |
| 169 | 151 | Icon: RustRoverIcon, |
| 170 | 152 | value: "rustrover", |
| 171 | 153 | kind: "brand", |
| 172 | 154 | }, |
| 173 | 155 | { |
| 174 | label: "WebStorm", | |
| 175 | 156 | Icon: WebStormIcon, |
| 176 | 157 | value: "webstorm", |
| 177 | 158 | kind: "brand", |
| 178 | 159 | }, |
| 179 | 160 | { |
| 180 | label: isMacPlatform(platform) | |
| 181 | ? "Finder" | |
| 182 | : isWindowsPlatform(platform) | |
| 183 | ? "Explorer" | |
| 184 | : "Files", | |
| 185 | 161 | Icon: FolderClosedIcon, |
| 186 | 162 | value: "file-manager", |
| 187 | 163 | kind: "generic", |
| 188 | 164 | }, |
| 189 | 165 | ]; |
| 190 | 166 | const availableEditorSet = new Set(availableEditors); |
| 191 | return baseOptions.filter((option) => availableEditorSet.has(option.value)); | |
| 167 | return baseOptions | |
| 168 | .filter((option) => availableEditorSet.has(option.value)) | |
| 169 | .map((option) => ({ ...option, label: editorLabelForPlatform(option.value, platform) })); | |
| 192 | 170 | }; |
| 193 | 171 | |
| 194 | 172 | function getOpenInIconClass(kind: OpenInOption["kind"]) { |
apps/web/src/components/preview/fileExplorerLabel.test.ts+26-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | |
| 3 | import { | |
| 4 | revealInFileExplorerLabel, | |
| 5 | revealInFileExplorerLabelForKind, | |
| 6 | revealInFileExplorerLabelForOs, | |
| 7 | } from "./fileExplorerLabel"; | |
| 4 | 8 | |
| 5 | 9 | describe("revealInFileExplorerLabel", () => { |
| 6 | 10 | it.each([ |
| ... | ... | @@ -11,3 +15,24 @@ describe("revealInFileExplorerLabel", () => { |
| 11 | 15 | expect(revealInFileExplorerLabel(platform)).toBe(expected); |
| 12 | 16 | }); |
| 13 | 17 | }); |
| 18 | ||
| 19 | describe("revealInFileExplorerLabelForOs", () => { | |
| 20 | it.each([ | |
| 21 | ["darwin", "Reveal in Finder"], | |
| 22 | ["windows", "Reveal in File Explorer"], | |
| 23 | ["linux", "Reveal in Files"], | |
| 24 | ["unknown", "Reveal in Files"], | |
| 25 | ] as const)("maps %s to %s", (os, expected) => { | |
| 26 | expect(revealInFileExplorerLabelForOs(os)).toBe(expected); | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | describe("revealInFileExplorerLabelForKind", () => { | |
| 31 | it.each([ | |
| 32 | ["finder", "Reveal in Finder"], | |
| 33 | ["file-explorer", "Reveal in File Explorer"], | |
| 34 | ["files", "Reveal in Files"], | |
| 35 | ] as const)("maps %s to %s", (kind, expected) => { | |
| 36 | expect(revealInFileExplorerLabelForKind(kind)).toBe(expected); | |
| 37 | }); | |
| 38 | }); |
apps/web/src/components/preview/fileExplorerLabel.ts+17| ... | ... | @@ -1,6 +1,23 @@ |
| 1 | import type { ExecutionEnvironmentPlatformOs, FileManagerRevealKind } from "@t3tools/contracts"; | |
| 2 | ||
| 1 | 3 | export function revealInFileExplorerLabel(platform: string): string { |
| 2 | 4 | const normalized = platform.toLowerCase(); |
| 3 | 5 | if (normalized.includes("mac")) return "Reveal in Finder"; |
| 4 | 6 | if (normalized.includes("win")) return "Reveal in File Explorer"; |
| 5 | 7 | return "Reveal in Files"; |
| 6 | 8 | } |
| 9 | ||
| 10 | /** Same wording keyed by an environment's reported OS rather than a | |
| 11 | navigator platform string, for actions that reveal on the server machine. */ | |
| 12 | export function revealInFileExplorerLabelForOs(os: ExecutionEnvironmentPlatformOs): string { | |
| 13 | if (os === "darwin") return "Reveal in Finder"; | |
| 14 | if (os === "windows") return "Reveal in File Explorer"; | |
| 15 | return "Reveal in Files"; | |
| 16 | } | |
| 17 | ||
| 18 | /** Server-selected wording, including Windows File Explorer reached from WSL. */ | |
| 19 | export function revealInFileExplorerLabelForKind(kind: FileManagerRevealKind): string { | |
| 20 | if (kind === "finder") return "Reveal in Finder"; | |
| 21 | if (kind === "file-explorer") return "Reveal in File Explorer"; | |
| 22 | return "Reveal in Files"; | |
| 23 | } |
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+11-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { cn } from "~/lib/utils"; |
| 4 | 5 | |
| ... | ... | @@ -19,10 +20,12 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; |
| 19 | 20 | export function PullRequestMarkdown({ |
| 20 | 21 | text, |
| 21 | 22 | cwd, |
| 23 | environmentId, | |
| 22 | 24 | className, |
| 23 | 25 | }: { |
| 24 | 26 | text: string; |
| 25 | 27 | cwd: string; |
| 28 | environmentId: EnvironmentId; | |
| 26 | 29 | className?: string; |
| 27 | 30 | }) { |
| 28 | 31 | const segments = splitPullRequestBody(text); |
| ... | ... | @@ -30,7 +33,14 @@ export function PullRequestMarkdown({ |
| 30 | 33 | <div className={cn("space-y-3", className)}> |
| 31 | 34 | {segments.map((segment) => { |
| 32 | 35 | if (segment.kind === "markdown") { |
| 33 | return <ChatMarkdown key={segment.id} text={segment.text} cwd={cwd} />; | |
| 36 | return ( | |
| 37 | <ChatMarkdown | |
| 38 | key={segment.id} | |
| 39 | text={segment.text} | |
| 40 | cwd={cwd} | |
| 41 | environmentId={environmentId} | |
| 42 | /> | |
| 43 | ); | |
| 34 | 44 | } |
| 35 | 45 | const isVideo = segment.media === "video"; |
| 36 | 46 | const Icon = isVideo ? PlayIcon : PaperclipIcon; |
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx+4-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { useState } from "react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { cn } from "~/lib/utils"; |
| 4 | 5 | |
| ... | ... | @@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 17 | 18 | export function PullRequestMarkdownEditor({ |
| 18 | 19 | value, |
| 19 | 20 | cwd, |
| 21 | environmentId, | |
| 20 | 22 | placeholder, |
| 21 | 23 | label, |
| 22 | 24 | saving, |
| ... | ... | @@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({ |
| 27 | 29 | }: { |
| 28 | 30 | readonly value: string; |
| 29 | 31 | readonly cwd: string; |
| 32 | readonly environmentId: EnvironmentId; | |
| 30 | 33 | readonly placeholder?: string | undefined; |
| 31 | 34 | readonly label: string; |
| 32 | 35 | readonly saving: boolean; |
| ... | ... | @@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({ |
| 81 | 84 | {empty ? ( |
| 82 | 85 | <p className="text-xs text-muted-foreground">Nothing to preview.</p> |
| 83 | 86 | ) : ( |
| 84 | <PullRequestMarkdown text={draft} cwd={cwd} /> | |
| 87 | <PullRequestMarkdown text={draft} cwd={cwd} environmentId={environmentId} /> | |
| 85 | 88 | )} |
| 86 | 89 | </div> |
| 87 | 90 | ) : ( |
apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx+2| ... | ... | @@ -273,6 +273,7 @@ export function ReviewThreadCard({ |
| 273 | 273 | className="mt-1" |
| 274 | 274 | value={comment.body} |
| 275 | 275 | cwd={workspaceRoot} |
| 276 | environmentId={environmentId} | |
| 276 | 277 | label="Edit comment" |
| 277 | 278 | saving={savingEdit} |
| 278 | 279 | onSave={(body) => void saveEdit(comment.id, body)} |
| ... | ... | @@ -284,6 +285,7 @@ export function ReviewThreadCard({ |
| 284 | 285 | className="min-w-0 flex-1 text-sm" |
| 285 | 286 | text={comment.body} |
| 286 | 287 | cwd={workspaceRoot} |
| 288 | environmentId={environmentId} | |
| 287 | 289 | /> |
| 288 | 290 | {canEditComment(comment) ? ( |
| 289 | 291 | <Button |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+11-1| ... | ... | @@ -94,6 +94,7 @@ function reviewStateLabel(state: string): string { |
| 94 | 94 | /** What every remark in the conversation needs to be rewritten where it sits. */ |
| 95 | 95 | interface CommentEditing { |
| 96 | 96 | readonly cwd: string; |
| 97 | readonly environmentId: EnvironmentId; | |
| 97 | 98 | readonly canEdit: (comment: PullRequestComment) => boolean; |
| 98 | 99 | readonly editingId: string | null; |
| 99 | 100 | readonly saving: boolean; |
| ... | ... | @@ -120,6 +121,7 @@ function CommentBody({ |
| 120 | 121 | className={className} |
| 121 | 122 | value={comment.body} |
| 122 | 123 | cwd={editing.cwd} |
| 124 | environmentId={editing.environmentId} | |
| 123 | 125 | label="Edit comment" |
| 124 | 126 | saving={editing.saving} |
| 125 | 127 | onSave={(body) => editing.onSave(comment, body)} |
| ... | ... | @@ -129,7 +131,12 @@ function CommentBody({ |
| 129 | 131 | } |
| 130 | 132 | return ( |
| 131 | 133 | <div className={cn("flex items-start gap-1", className)}> |
| 132 | <PullRequestMarkdown className="min-w-0 flex-1" text={comment.body} cwd={editing.cwd} /> | |
| 134 | <PullRequestMarkdown | |
| 135 | className="min-w-0 flex-1" | |
| 136 | text={comment.body} | |
| 137 | cwd={editing.cwd} | |
| 138 | environmentId={editing.environmentId} | |
| 139 | /> | |
| 133 | 140 | {editing.canEdit(comment) ? ( |
| 134 | 141 | <Button |
| 135 | 142 | size="icon-xs" |
| ... | ... | @@ -489,6 +496,7 @@ export function PullRequestSummaryTab({ |
| 489 | 496 | |
| 490 | 497 | const commentEditing: CommentEditing = { |
| 491 | 498 | cwd: detail.workspaceRoot, |
| 499 | environmentId, | |
| 492 | 500 | canEdit: (comment) => canEditPullRequestComment(detail, comment), |
| 493 | 501 | editingId: editingCommentId, |
| 494 | 502 | saving: commentSaving, |
| ... | ... | @@ -649,6 +657,7 @@ export function PullRequestSummaryTab({ |
| 649 | 657 | allowEmpty |
| 650 | 658 | value={detail.body} |
| 651 | 659 | cwd={detail.workspaceRoot} |
| 660 | environmentId={environmentId} | |
| 652 | 661 | label="Pull request description" |
| 653 | 662 | placeholder="Describe this pull request" |
| 654 | 663 | saving={bodySaving} |
| ... | ... | @@ -661,6 +670,7 @@ export function PullRequestSummaryTab({ |
| 661 | 670 | className="min-w-0 flex-1" |
| 662 | 671 | text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"} |
| 663 | 672 | cwd={detail.workspaceRoot} |
| 673 | environmentId={environmentId} | |
| 664 | 674 | /> |
| 665 | 675 | {canEditPullRequestChangeRequest(detail) ? ( |
| 666 | 676 | <Button |
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+25-4| ... | ... | @@ -59,11 +59,21 @@ interface ReactionSurface { |
| 59 | 59 | readonly onRefresh: () => void; |
| 60 | 60 | } |
| 61 | 61 | |
| 62 | function TimelineBody({ body, markdown, cwd }: { body: string; markdown: boolean; cwd: string }) { | |
| 62 | function TimelineBody({ | |
| 63 | body, | |
| 64 | markdown, | |
| 65 | cwd, | |
| 66 | environmentId, | |
| 67 | }: { | |
| 68 | body: string; | |
| 69 | markdown: boolean; | |
| 70 | cwd: string; | |
| 71 | environmentId: EnvironmentId; | |
| 72 | }) { | |
| 63 | 73 | return ( |
| 64 | 74 | <div className="mt-3"> |
| 65 | 75 | {markdown ? ( |
| 66 | <PullRequestMarkdown text={body} cwd={cwd} /> | |
| 76 | <PullRequestMarkdown text={body} cwd={cwd} environmentId={environmentId} /> | |
| 67 | 77 | ) : ( |
| 68 | 78 | <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p> |
| 69 | 79 | )} |
| ... | ... | @@ -235,6 +245,7 @@ function ConversationCard({ |
| 235 | 245 | <PullRequestMarkdownEditor |
| 236 | 246 | value={editable.body} |
| 237 | 247 | cwd={cwd} |
| 248 | environmentId={reactions.environmentId} | |
| 238 | 249 | label="Edit comment" |
| 239 | 250 | saving={saving} |
| 240 | 251 | onSave={(body) => void save(body)} |
| ... | ... | @@ -243,7 +254,12 @@ function ConversationCard({ |
| 243 | 254 | </div> |
| 244 | 255 | ) : event.body ? ( |
| 245 | 256 | <div className="px-2 pb-2"> |
| 246 | <TimelineBody body={event.body} markdown={event.markdown} cwd={cwd} /> | |
| 257 | <TimelineBody | |
| 258 | body={event.body} | |
| 259 | markdown={event.markdown} | |
| 260 | cwd={cwd} | |
| 261 | environmentId={reactions.environmentId} | |
| 262 | /> | |
| 247 | 263 | </div> |
| 248 | 264 | ) : null} |
| 249 | 265 | {reactions.canReact || event.reactions.length > 0 ? ( |
| ... | ... | @@ -505,7 +521,12 @@ function ReviewVerdictEvent({ |
| 505 | 521 | {/* An approval usually carries no words. When it does they are the review, so they stay |
| 506 | 522 | visible rather than being folded away with the ordinary conversation. */} |
| 507 | 523 | {event.body ? ( |
| 508 | <TimelineBody body={event.body} markdown={event.markdown} cwd={cwd} /> | |
| 524 | <TimelineBody | |
| 525 | body={event.body} | |
| 526 | markdown={event.markdown} | |
| 527 | cwd={cwd} | |
| 528 | environmentId={reactions.environmentId} | |
| 529 | /> | |
| 509 | 530 | ) : null} |
| 510 | 531 | </div> |
| 511 | 532 | <OpenOnHostButton url={event.url} onOpen={onOpen} /> |
apps/web/src/editorLabels.test.ts created+29| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { editorLabelForPlatform, openInEditorMenuLabel } from "./editorLabels"; | |
| 4 | ||
| 5 | describe("editorLabelForPlatform", () => { | |
| 6 | it("uses the editor name from the shared editor definitions", () => { | |
| 7 | expect(editorLabelForPlatform("cursor", "MacIntel")).toBe("Cursor"); | |
| 8 | expect(editorLabelForPlatform("vscode-insiders", "Win32")).toBe("VS Code Insiders"); | |
| 9 | }); | |
| 10 | ||
| 11 | it.each([ | |
| 12 | ["MacIntel", "Finder"], | |
| 13 | ["Win32", "Explorer"], | |
| 14 | ["Linux x86_64", "Files"], | |
| 15 | ])("uses the platform file-manager name on %s", (platform, label) => { | |
| 16 | expect(editorLabelForPlatform("file-manager", platform)).toBe(label); | |
| 17 | }); | |
| 18 | }); | |
| 19 | ||
| 20 | describe("openInEditorMenuLabel", () => { | |
| 21 | it("names the preferred editor", () => { | |
| 22 | expect(openInEditorMenuLabel("zed")).toBe("Open in Zed"); | |
| 23 | }); | |
| 24 | ||
| 25 | it("keeps the generic label for the default file handler and missing preferences", () => { | |
| 26 | expect(openInEditorMenuLabel("file-manager")).toBe("Open in editor"); | |
| 27 | expect(openInEditorMenuLabel(null)).toBe("Open in editor"); | |
| 28 | }); | |
| 29 | }); |
apps/web/src/editorLabels.ts created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { getLocalFileManagerName } from "~/lib/utils"; | |
| 4 | ||
| 5 | const editorLabels = new Map<EditorId, string>(EDITORS.map((editor) => [editor.id, editor.label])); | |
| 6 | ||
| 7 | export function editorLabelForPlatform(editorId: EditorId, platform: string): string { | |
| 8 | if (editorId === "file-manager") { | |
| 9 | return getLocalFileManagerName(platform); | |
| 10 | } | |
| 11 | ||
| 12 | return editorLabels.get(editorId) ?? "Editor"; | |
| 13 | } | |
| 14 | ||
| 15 | export function openInEditorMenuLabel(editorId: EditorId | null): string { | |
| 16 | return editorId === null || editorId === "file-manager" | |
| 17 | ? "Open in editor" | |
| 18 | : `Open in ${editorLabels.get(editorId) ?? "Editor"}`; | |
| 19 | } |
apps/web/src/index.css+3-3| ... | ... | @@ -2271,13 +2271,13 @@ code { |
| 2271 | 2271 | font-size: 0.75rem; |
| 2272 | 2272 | } |
| 2273 | 2273 | |
| 2274 | .chat-markdown a.chat-markdown-file-link, | |
| 2275 | .chat-markdown a.chat-markdown-file-link:hover { | |
| 2274 | .chat-markdown .chat-markdown-file-link, | |
| 2275 | .chat-markdown .chat-markdown-file-link:hover { | |
| 2276 | 2276 | color: var(--contrast-foreground); |
| 2277 | 2277 | text-decoration: none; |
| 2278 | 2278 | } |
| 2279 | 2279 | |
| 2280 | .chat-markdown a.chat-markdown-file-link:focus-visible { | |
| 2280 | .chat-markdown .chat-markdown-file-link:focus-visible { | |
| 2281 | 2281 | outline: none; |
| 2282 | 2282 | box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 70%, transparent); |
| 2283 | 2283 | } |
apps/web/src/remoteOpen.ts+24-8| ... | ... | @@ -36,8 +36,17 @@ export type RemoteOpenState = |
| 36 | 36 | |
| 37 | 37 | export type RemoteOpenMode = RemoteOpenState["mode"]; |
| 38 | 38 | |
| 39 | export interface RemoteOpenResolution { | |
| 40 | readonly state: RemoteOpenState; | |
| 41 | readonly isResolved: boolean; | |
| 42 | } | |
| 43 | ||
| 39 | 44 | const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; |
| 40 | 45 | const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; |
| 46 | const UNRESOLVED_REMOTE_OPEN: RemoteOpenResolution = { | |
| 47 | state: LOCAL_EXEC, | |
| 48 | isResolved: false, | |
| 49 | }; | |
| 41 | 50 | |
| 42 | 51 | function parseHostname(url: string): string | null { |
| 43 | 52 | try { |
| ... | ... | @@ -87,25 +96,32 @@ export function resolveRemoteOpenState(input: { |
| 87 | 96 | return REMOTE_UNAVAILABLE; |
| 88 | 97 | } |
| 89 | 98 | |
| 90 | export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { | |
| 99 | export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution { | |
| 91 | 100 | const { presentation } = useEnvironmentPresentation(environmentId); |
| 92 | 101 | |
| 93 | 102 | return useMemo(() => { |
| 94 | 103 | if (presentation === null) { |
| 95 | return LOCAL_EXEC; | |
| 104 | return UNRESOLVED_REMOTE_OPEN; | |
| 96 | 105 | } |
| 97 | 106 | const profile = Option.getOrNull(presentation.entry.profile); |
| 98 | 107 | const sshAlias = |
| 99 | 108 | profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null; |
| 100 | return resolveRemoteOpenState({ | |
| 101 | target: presentation.entry.target, | |
| 102 | sshAlias, | |
| 103 | remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, | |
| 104 | isDesktopRenderer: window.desktopBridge !== undefined, | |
| 105 | }); | |
| 109 | return { | |
| 110 | state: resolveRemoteOpenState({ | |
| 111 | target: presentation.entry.target, | |
| 112 | sshAlias, | |
| 113 | remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, | |
| 114 | isDesktopRenderer: window.desktopBridge !== undefined, | |
| 115 | }), | |
| 116 | isResolved: true, | |
| 117 | }; | |
| 106 | 118 | }, [presentation]); |
| 107 | 119 | } |
| 108 | 120 | |
| 121 | export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { | |
| 122 | return useRemoteOpenResolution(environmentId).state; | |
| 123 | } | |
| 124 | ||
| 109 | 125 | /** |
| 110 | 126 | * Editors offered in remote-link mode. The desktop app probes the machine the |
| 111 | 127 | * renderer runs on; a browser cannot, so it offers VS Code only. |
packages/contracts/src/editor.ts+7| ... | ... | @@ -69,9 +69,16 @@ export const EDITORS = [ |
| 69 | 69 | export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); |
| 70 | 70 | export type EditorId = typeof EditorId.Type; |
| 71 | 71 | |
| 72 | export const FileManagerRevealKind = Schema.Literals(["finder", "file-explorer", "files"]); | |
| 73 | export type FileManagerRevealKind = typeof FileManagerRevealKind.Type; | |
| 74 | ||
| 72 | 75 | export const LaunchEditorInput = Schema.Struct({ |
| 73 | 76 | cwd: TrimmedNonEmptyString, |
| 74 | 77 | editor: EditorId, |
| 78 | /** Reveal (select) `cwd` in the file manager instead of opening it. Only | |
| 79 | honored by the "file-manager" editor; clients must check the server's | |
| 80 | `shellRevealInFileManager` config flag before sending this. */ | |
| 81 | reveal: Schema.optional(Schema.Boolean), | |
| 75 | 82 | }); |
| 76 | 83 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; |
| 77 | 84 |
packages/contracts/src/server.ts+6-1| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | KeybindingWhen, |
| 18 | 18 | ResolvedKeybindingsConfig, |
| 19 | 19 | } from "./keybindings.ts"; |
| 20 | import { EditorId, RemoteOpenTarget } from "./editor.ts"; | |
| 20 | import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts"; | |
| 21 | 21 | import { ModelCapabilities } from "./model.ts"; |
| 22 | 22 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 23 | 23 | import { ServerSettings } from "./settings.ts"; |
| ... | ... | @@ -438,6 +438,11 @@ export const ServerConfig = Schema.Struct({ |
| 438 | 438 | settings: ServerSettings, |
| 439 | 439 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ |
| 440 | 440 | shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), |
| 441 | /** Whether shell.openInEditor honors `LaunchEditorInput.reveal` for the | |
| 442 | file-manager editor. */ | |
| 443 | shellRevealInFileManager: Schema.optionalKey(Schema.Boolean), | |
| 444 | /** File-manager wording clients should use for reveal actions. */ | |
| 445 | shellRevealInFileManagerKind: Schema.optionalKey(FileManagerRevealKind), | |
| 441 | 446 | /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ |
| 442 | 447 | threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), |
| 443 | 448 | /** |