authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-25 15:05:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-26 00:35:16+05:30
log082e6ea521861fff37b90fcd789b5eaa5ef5d6a6
treed12206848dd49c269b8f1d73effcfc661ca86c55
parente67074f80933a27bd3cdc4e24f486358407690fb
signature Signed by PGP key B5690EEEBB952194

feat(web): reveal chat file chips in the system file manager (#7140)

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,6 +70,13 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put
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.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- 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.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.
7272
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## Change discipline80## Change discipline
7481
75- Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup.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,3 +1,8 @@
1// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
1import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";7import { assert, it } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";8import * as ConfigProvider from "effect/ConfigProvider";
...@@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";...@@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
15import { SpawnExecutableResolution } from "@t3tools/shared/shell";20import { SpawnExecutableResolution } from "@t3tools/shared/shell";
16import * as ExternalLauncher from "./externalLauncher.ts";21import * as ExternalLauncher from "./externalLauncher.ts";
1722
18function makeMockDetachedHandle(onUnref: () => void = () => undefined) {23interface 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
30function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) {
19 return ChildProcessSpawner.makeHandle({31 return ChildProcessSpawner.makeHandle({
20 pid: ChildProcessSpawner.ProcessId(1),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 isRunning: Effect.succeed(true),36 isRunning: Effect.succeed(true),
23 kill: () => Effect.void,37 kill: () => Effect.void,
24 unref: Effect.sync(() => {38 unref: Effect.sync(() => {
25 onUnref();39 input.onUnref?.();
26 return Effect.void;40 return Effect.void;
27 }),41 }),
28 stdin: Sink.drain,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 stderr: Stream.empty,47 stderr: Stream.empty,
31 all: Stream.empty,48 all: Stream.empty,
32 getInputFd: () => Sink.drain,49 getInputFd: () => Sink.drain,
...@@ -40,6 +57,7 @@ const testLayer = (input: {...@@ -40,6 +57,7 @@ const testLayer = (input: {
40 readonly resolveExecutable?: (command: string) => string | undefined;57 readonly resolveExecutable?: (command: string) => string | undefined;
41 readonly onSpawn?: (command: ChildProcess.StandardCommand) => void;58 readonly onSpawn?: (command: ChildProcess.StandardCommand) => void;
42 readonly onUnref?: () => void;59 readonly onUnref?: () => void;
60 readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined;
43}) => {61}) => {
44 const spawnerLayer = Layer.succeed(62 const spawnerLayer = Layer.succeed(
45 ChildProcessSpawner.ChildProcessSpawner,63 ChildProcessSpawner.ChildProcessSpawner,
...@@ -50,7 +68,10 @@ const testLayer = (input: {...@@ -50,7 +68,10 @@ const testLayer = (input: {
50 throw new Error("Expected a standard command");68 throw new Error("Expected a standard command");
51 }69 }
52 input.onSpawn?.(command);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,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
132 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),153 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
133);154);
134155
156it.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
191it.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.
254it.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
300it.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
333it.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
386it.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.
422it.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.
473it.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
522it.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
567it.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
606it.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
624it.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.
663it.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
691it.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.
726it.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
755it.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
135it.effect("discovers editors through the service API", () =>773it.effect("discovers editors through the service API", () =>
136 Effect.gen(function* () {774 Effect.gen(function* () {
137 const fileSystem = yield* FileSystem.FileSystem;775 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/process/externalLauncher.ts+312-19
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 ExternalLauncherUnknownEditorError,15 ExternalLauncherUnknownEditorError,
16 ExternalLauncherUnsupportedEditorError,16 ExternalLauncherUnsupportedEditorError,
17 type EditorId,17 type EditorId,
18 type FileManagerRevealKind,
18 type LaunchEditorInput,19 type LaunchEditorInput,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
20import { HostProcessPlatform } from "@t3tools/shared/hostProcess";21import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
...@@ -29,6 +30,7 @@ import * as Layer from "effect/Layer";...@@ -29,6 +30,7 @@ import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";30import * as Option from "effect/Option";
30import * as Path from "effect/Path";31import * as Path from "effect/Path";
31import * as Ref from "effect/Ref";32import * as Ref from "effect/Ref";
33import * as Stream from "effect/Stream";
32import * as ChildProcess from "effect/unstable/process/ChildProcess";34import * as ChildProcess from "effect/unstable/process/ChildProcess";
33import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";35import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
3436
...@@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({...@@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({
99 SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option),101 SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option),
100 SSH_TTY: Config.string("SSH_TTY").pipe(Config.option),102 SSH_TTY: Config.string("SSH_TTY").pipe(Config.option),
101 container: Config.string("container").pipe(Config.option),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}).pipe(Config.map(compactEnv));106}).pipe(Config.map(compactEnv));
103107
104const CommandLookupEnvConfig = Config.all({108const CommandLookupEnvConfig = Config.all({
...@@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string {...@@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string {
193 return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";197 return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
194}198}
195199
196function 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.
204const WSL_POWERSHELL_COMMAND = "powershell.exe";
205
206function shouldUseWindowsHostFromWsl(
197 platform: NodeJS.Platform,207 platform: NodeJS.Platform,
198 env: NodeJS.ProcessEnv = {},208 env: NodeJS.ProcessEnv = {},
199): boolean {209): boolean {
...@@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa...@@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa
223 };233 };
224}234}
225235
226function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {236function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean {
237 return [env.DISPLAY, env.WAYLAND_DISPLAY].some(
238 (value) => value !== undefined && value.trim().length > 0,
239 );
240}
241
242function fileManagerCommandForPlatform(
243 platform: NodeJS.Platform,
244 env: NodeJS.ProcessEnv,
245): string | undefined {
227 switch (platform) {246 switch (platform) {
228 case "darwin":247 case "darwin":
229 return "open";248 return "open";
230 case "win32":249 case "win32":
231 return "explorer";250 return "explorer";
232 default: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}
236258
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.
269const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds";
270
271const 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
305const 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.
326const 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.
357const 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
388function resolveWslFileManagerPath(target: string, distroName: string): string {
389 const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\");
390 return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`;
391}
392
237function buildBrowserLaunch(393function buildBrowserLaunch(
238 target: string,394 target: string,
239 platform: NodeJS.Platform,395 platform: NodeJS.Platform,
...@@ -251,7 +407,7 @@ function buildBrowserLaunch(...@@ -251,7 +407,7 @@ function buildBrowserLaunch(
251 return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env));407 return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env));
252 }408 }
253409
254 if (shouldUseWindowsBrowserFromWsl(platform, env)) {410 if (shouldUseWindowsHostFromWsl(platform, env)) {
255 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath());411 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath());
256 }412 }
257413
...@@ -265,13 +421,16 @@ function buildBrowserLaunch(...@@ -265,13 +421,16 @@ function buildBrowserLaunch(
265const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* (421const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* (
266 platform: NodeJS.Platform,422 platform: NodeJS.Platform,
267 env: NodeJS.ProcessEnv,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 const available: EditorId[] = [];429 const available: EditorId[] = [];
270430
271 for (const editor of EDITORS) {431 for (const editor of EDITORS) {
272 if (editor.commands === null) {432 if (editor.commands === null) {
273 const command = fileManagerCommandForPlatform(platform);433 if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) {
274 if (yield* isCommandAvailable(command, { env })) {
275 available.push(editor.id);434 available.push(editor.id);
276 }435 }
277 continue;436 continue;
...@@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(...@@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(
296455
297const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {456const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
298 const platform = yield* HostProcessPlatform;457 const platform = yield* HostProcessPlatform;
299 const env = yield* readCommandLookupEnv;458 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
300 return yield* buildAvailableEditors(platform, env);459 return yield* buildAvailableEditors(platform, env);
301});460});
302461
462const 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// Editor discovery walks PATH for every known editor and runs for every470// Editor discovery walks PATH for every known editor and runs for every
304// client connect (the server config embeds the available editors). Memoize471// client connect (the server config embeds the available editors). Memoize
305// the discovered set for a bounded window so repeat connects skip even the472// the discovered set for a bounded window so repeat connects skip even the
...@@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service<...@@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service<
329 ExternalLauncher,496 ExternalLauncher,
330 {497 {
331 readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>;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 /** Launch a URL target in the default browser. */507 /** Launch a URL target in the default browser. */
333 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;508 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
334 /**509 /**
...@@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service<...@@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service<
346521
347const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (522const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
348 input: LaunchEditorInput,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 const platform = yield* HostProcessPlatform;529 const platform = yield* HostProcessPlatform;
351 const env = yield* readCommandLookupEnv;530 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
352 yield* Effect.annotateCurrentSpan({531 yield* Effect.annotateCurrentSpan({
353 "externalLauncher.editor": input.editor,532 "externalLauncher.editor": input.editor,
354 "externalLauncher.cwd": input.cwd,533 "externalLauncher.cwd": input.cwd,
...@@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (...@@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
376 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });555 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
377 }556 }
378557
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 return {567 return {
380 editor: editorDef.id,568 editor: editorDef.id,
381 target: input.cwd,569 target: input.cwd,
382 command: fileManagerCommandForPlatform(platform),570 command,
383 args: [input.cwd],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});
386577
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 */
589export function buildFileExplorerRevealPowerShellSource(
590 explorerCommand: string,
591 target: string,
592): string {
593 return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`;
594}
595
596function 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
612const 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
387const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (678const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
388 launch: ProcessLaunch,679 launch: ProcessLaunch,
389 onError: (cause: unknown) => ExternalLauncherError,680 onError: (cause: unknown) => ExternalLauncherError,
...@@ -476,7 +767,9 @@ export const make = Effect.gen(function* () {...@@ -476,7 +767,9 @@ export const make = Effect.gen(function* () {
476 if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) {767 if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) {
477 return entry.value.editors;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 yield* Ref.set(773 yield* Ref.set(
481 editorDiscoveryCache,774 editorDiscoveryCache,
482 Option.some({775 Option.some({
...@@ -489,18 +782,18 @@ export const make = Effect.gen(function* () {...@@ -489,18 +782,18 @@ export const make = Effect.gen(function* () {
489782
490 return ExternalLauncher.of({783 return ExternalLauncher.of({
491 resolveAvailableEditors: () => cachedAvailableEditors,784 resolveAvailableEditors: () => cachedAvailableEditors,
785 resolveFileManagerRevealKind: () =>
786 provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe(
787 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
788 ),
492 launchBrowser: (target) =>789 launchBrowser: (target) =>
493 launchBrowser(target).pipe(790 launchBrowser(target).pipe(
494 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),791 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
495 ),792 ),
496 launchEditor: (input) =>793 launchEditor: (input) =>
497 provideCommandResolutionServices(794 provideCommandResolutionServices(
498 Effect.flatMap(resolveEditorLaunch(input), (launch) =>795 Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess),
499 launchEditorProcess(launch).pipe(796 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
500 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
501 ),
502 ),
503 ),
504 });797 });
505});798});
506799
apps/server/src/server.test.ts+53-2
...@@ -102,7 +102,11 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function...@@ -102,7 +102,11 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function
102import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";102import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
103import * as ServerConfig from "./config.ts";103import * as ServerConfig from "./config.ts";
104import { makeRoutesLayer } from "./server.ts";104import { makeRoutesLayer } from "./server.ts";
105import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts";105import {
106 isThreadDetailEvent,
107 resolveAvailableEditorsForConfig,
108 resolveFileManagerRevealKindForConfig,
109} from "./ws.ts";
106import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";110import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
107import * as GitManager from "./git/GitManager.ts";111import * as GitManager from "./git/GitManager.ts";
108import * as Keybindings from "./keybindings.ts";112import * as Keybindings from "./keybindings.ts";
...@@ -665,6 +669,7 @@ const buildAppUnderTest = (options?: {...@@ -665,6 +669,7 @@ const buildAppUnderTest = (options?: {
665 Layer.mergeAll(669 Layer.mergeAll(
666 Layer.mock(ExternalLauncher.ExternalLauncher)({670 Layer.mock(ExternalLauncher.ExternalLauncher)({
667 resolveAvailableEditors: () => Effect.succeed([]),671 resolveAvailableEditors: () => Effect.succeed([]),
672 resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined),
668 ...options?.layers?.externalLauncher,673 ...options?.layers?.externalLauncher,
669 }),674 }),
670 Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({675 Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({
...@@ -4031,10 +4036,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4031,10 +4036,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4031 assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId);4036 assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId);
4032 assert.equal(response.auth.policy, "desktop-managed-local");4037 assert.equal(response.auth.policy, "desktop-managed-local");
4033 assert.equal(response.shellResumeCompletionMarker, true);4038 assert.equal(response.shellResumeCompletionMarker, true);
4039 assert.isUndefined(response.shellRevealInFileManager);
4040 assert.isUndefined(response.shellRevealInFileManagerKind);
4034 assert.equal(response.threadResumeCompletionMarker, true);4041 assert.equal(response.threadResumeCompletionMarker, true);
4035 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4042 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4036 );4043 );
40374044
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 it.effect("does not block server config when editor discovery never resolves", () =>4071 it.effect("does not block server config when editor discovery never resolves", () =>
4039 Effect.gen(function* () {4072 Effect.gen(function* () {
4040 const discoveryInterrupted = yield* Deferred.make<void>();4073 const discoveryInterrupted = yield* Deferred.make<void>();
...@@ -4052,6 +4085,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4052,6 +4085,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4052 }),4085 }),
4053 );4086 );
40544087
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 it.effect(4105 it.effect(
4056 "rejects websocket rpc handshake when a session token is only provided via query string",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,6 +4696,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
46464696
4647 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>4697 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
4648 Effect.gen(function* () {4698 Effect.gen(function* () {
4699 const path = yield* Path.Path;
4649 const providers = [4700 const providers = [
4650 {4701 {
4651 instanceId: ProviderInstanceId.make("codex"),4702 instanceId: ProviderInstanceId.make("codex"),
...@@ -4699,7 +4750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4699,7 +4750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4699 assert.deepEqual(first.config.keybindings, []);4750 assert.deepEqual(first.config.keybindings, []);
4700 assert.deepEqual(first.config.issues, []);4751 assert.deepEqual(first.config.issues, []);
4701 assert.deepEqual(first.config.providers, providers);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 assert.equal(first.config.observability.localTracingEnabled, true);4754 assert.equal(first.config.observability.localTracingEnabled, true);
4704 assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces");4755 assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces");
4705 assert.equal(first.config.observability.otlpTracesEnabled, true);4756 assert.equal(first.config.observability.otlpTracesEnabled, true);
apps/server/src/ws.ts+31-8
...@@ -19,6 +19,8 @@ import {...@@ -19,6 +19,8 @@ import {
19 CommandId,19 CommandId,
20 type DiscoveredLocalServerList,20 type DiscoveredLocalServerList,
21 EventId,21 EventId,
22 type EditorId,
23 type FileManagerRevealKind,
22 type OrchestrationClientOrigin,24 type OrchestrationClientOrigin,
23 type OrchestrationCommand,25 type OrchestrationCommand,
24 type GitActionProgressEvent,26 type GitActionProgressEvent,
...@@ -136,16 +138,25 @@ import * as RelayClient from "@t3tools/shared/relayClient";...@@ -136,16 +138,25 @@ import * as RelayClient from "@t3tools/shared/relayClient";
136const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);138const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
137139
138const nowIso = Effect.map(DateTime.now, DateTime.formatIso);140const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
139const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5);141const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5);
140142
141export const resolveAvailableEditorsForConfig = <A, E, R>(143const resolveDiscoveryForConfig = <A, E, R>(
142 discovery: Effect.Effect<ReadonlyArray<A>, E, R>,144 discovery: Effect.Effect<A, E, R>,
145 onTimeout: () => A,
143) =>146) =>
144 discovery.pipe(147 discovery.pipe(
145 Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT),148 Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT),
146 Effect.map(Option.getOrElse(() => [])),149 Effect.map(Option.getOrElse(onTimeout)),
147 );150 );
148151
152export const resolveAvailableEditorsForConfig = <A, E, R>(
153 discovery: Effect.Effect<ReadonlyArray<A>, E, R>,
154) => resolveDiscoveryForConfig(discovery, () => []);
155
156export const resolveFileManagerRevealKindForConfig = <E, R>(
157 discovery: Effect.Effect<FileManagerRevealKind | undefined, E, R>,
158) => resolveDiscoveryForConfig(discovery, () => undefined);
159
149function unexpectedCompatibilityError(error: never): never {160function unexpectedCompatibilityError(error: never): never {
150 throw new Error(`Unhandled compatibility error: ${String(error)}`);161 throw new Error(`Unhandled compatibility error: ${String(error)}`);
151}162}
...@@ -1109,6 +1120,14 @@ const makeWsRpcLayer = (...@@ -1109,6 +1120,14 @@ const makeWsRpcLayer = (
1109 );1120 );
1110 const environment = yield* serverEnvironment.getDescriptor;1121 const environment = yield* serverEnvironment.getDescriptor;
1111 const auth = yield* serverAuth.getDescriptor();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;
11121131
1113 return {1132 return {
1114 environment,1133 environment,
...@@ -1118,9 +1137,7 @@ const makeWsRpcLayer = (...@@ -1118,9 +1137,7 @@ const makeWsRpcLayer = (
1118 keybindings: keybindingsConfig.keybindings,1137 keybindings: keybindingsConfig.keybindings,
1119 issues: keybindingsConfig.issues,1138 issues: keybindingsConfig.issues,
1120 providers,1139 providers,
1121 availableEditors: yield* resolveAvailableEditorsForConfig(1140 availableEditors,
1122 externalLauncher.resolveAvailableEditors(),
1123 ),
1124 // Same discovery-with-timeout treatment as editors: a slow probe1141 // Same discovery-with-timeout treatment as editors: a slow probe
1125 // must not stall server.getConfig, so it degrades to no targets.1142 // must not stall server.getConfig, so it degrades to no targets.
1126 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(1143 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
...@@ -1138,6 +1155,12 @@ const makeWsRpcLayer = (...@@ -1138,6 +1155,12 @@ const makeWsRpcLayer = (
1138 },1155 },
1139 settings,1156 settings,
1140 shellResumeCompletionMarker: true,1157 shellResumeCompletionMarker: true,
1158 ...(fileManagerRevealKind === undefined
1159 ? {}
1160 : {
1161 shellRevealInFileManager: true,
1162 shellRevealInFileManagerKind: fileManagerRevealKind,
1163 }),
1141 threadResumeCompletionMarker: true,1164 threadResumeCompletionMarker: true,
1142 threadSnapshotPagination: true,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,10 +12,15 @@ vi.mock("../state/session", async (importOriginal) => ({
12}));12}));
13vi.mock("../state/entities", () => ({13vi.mock("../state/entities", () => ({
14 readThreadShell: () => null,14 readThreadShell: () => null,
15 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
16 useProjects: () => [],15 useProjects: () => [],
17}));16}));
18vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));17vi.mock("../remoteOpen", () => ({
18 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
19}));
20vi.mock("../editorPreferences", () => ({
21 useOpenInPreferredEditor: () => vi.fn(),
22 usePreferredEditor: () => [null, vi.fn()],
23}));
19vi.mock("~/lib/openPullRequestLink", () => ({24vi.mock("~/lib/openPullRequestLink", () => ({
20 findProjectForChangeRequest: () => undefined,25 findProjectForChangeRequest: () => undefined,
21 matchesLinkedPullRequestUrl: () => false,26 matchesLinkedPullRequestUrl: () => false,
...@@ -23,7 +28,124 @@ vi.mock("~/lib/openPullRequestLink", () => ({...@@ -23,7 +28,124 @@ vi.mock("~/lib/openPullRequestLink", () => ({
23 useOpenChangeRequestLink: () => vi.fn(),28 useOpenChangeRequestLink: () => vi.fn(),
24}));29}));
2530
26import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";31import ChatMarkdown, {
32 canUseMarkdownFileShellActions,
33 hasMarkdownFilePrimaryAction,
34 orderedListGutterStyle,
35 shouldUseMarkdownFileBrowserPrimaryAction,
36} from "./ChatMarkdown";
37
38describe("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
59describe("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
95describe("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
107describe("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});
27149
28describe("orderedListGutterStyle", () => {150describe("orderedListGutterStyle", () => {
29 it("leaves the default gutter alone for single-digit lists", () => {151 it("leaves the default gutter alone for single-digit lists", () => {
...@@ -67,10 +189,13 @@ describe("orderedListGutterStyle", () => {...@@ -67,10 +189,13 @@ describe("orderedListGutterStyle", () => {
67});189});
68190
69describe("ChatMarkdown Windows file links", () => {191describe("ChatMarkdown Windows file links", () => {
192 const environmentId = EnvironmentId.make("env-windows");
193
70 it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => {194 it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => {
71 const html = renderToStaticMarkup(195 const html = renderToStaticMarkup(
72 <ChatMarkdown196 <ChatMarkdown
73 cwd="C:/Users/shawn/project"197 cwd="C:/Users/shawn/project"
198 environmentId={environmentId}
74 text="[Open](C:/Users/shawn/project/src/main.ts)"199 text="[Open](C:/Users/shawn/project/src/main.ts)"
75 lineBreaks={!parseRawHtml}200 lineBreaks={!parseRawHtml}
76 parseRawHtml={parseRawHtml}201 parseRawHtml={parseRawHtml}
...@@ -85,6 +210,7 @@ describe("ChatMarkdown Windows file links", () => {...@@ -85,6 +210,7 @@ describe("ChatMarkdown Windows file links", () => {
85 const html = renderToStaticMarkup(210 const html = renderToStaticMarkup(
86 <ChatMarkdown211 <ChatMarkdown
87 cwd="C:/Users/shawn/project"212 cwd="C:/Users/shawn/project"
213 environmentId={environmentId}
88 text={String.raw`[Open](C:\Users\shawn\project\src\main.ts)`}214 text={String.raw`[Open](C:\Users\shawn\project\src\main.ts)`}
89 lineBreaks={!parseRawHtml}215 lineBreaks={!parseRawHtml}
90 parseRawHtml={parseRawHtml}216 parseRawHtml={parseRawHtml}
...@@ -101,6 +227,7 @@ describe("ChatMarkdown Windows file links", () => {...@@ -101,6 +227,7 @@ describe("ChatMarkdown Windows file links", () => {
101 const html = renderToStaticMarkup(227 const html = renderToStaticMarkup(
102 <ChatMarkdown228 <ChatMarkdown
103 cwd="C:/Users/shawn/project"229 cwd="C:/Users/shawn/project"
230 environmentId={environmentId}
104 text={String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`}231 text={String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`}
105 lineBreaks={!parseRawHtml}232 lineBreaks={!parseRawHtml}
106 parseRawHtml={parseRawHtml}233 parseRawHtml={parseRawHtml}
...@@ -119,6 +246,7 @@ describe("ChatMarkdown Windows file links", () => {...@@ -119,6 +246,7 @@ describe("ChatMarkdown Windows file links", () => {
119 const html = renderToStaticMarkup(246 const html = renderToStaticMarkup(
120 <ChatMarkdown247 <ChatMarkdown
121 cwd="C:/Users/shawn/project"248 cwd="C:/Users/shawn/project"
249 environmentId={environmentId}
122 text={`[Source](${path}) and \`${path}\``}250 text={`[Source](${path}) and \`${path}\``}
123 lineBreaks={!parseRawHtml}251 lineBreaks={!parseRawHtml}
124 parseRawHtml={parseRawHtml}252 parseRawHtml={parseRawHtml}
...@@ -134,6 +262,7 @@ describe("ChatMarkdown Windows file links", () => {...@@ -134,6 +262,7 @@ describe("ChatMarkdown Windows file links", () => {
134 const html = renderToStaticMarkup(262 const html = renderToStaticMarkup(
135 <ChatMarkdown263 <ChatMarkdown
136 cwd="C:/Users/shawn/project"264 cwd="C:/Users/shawn/project"
265 environmentId={environmentId}
137 text={"[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"}266 text={"[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"}
138 lineBreaks={!parseRawHtml}267 lineBreaks={!parseRawHtml}
139 parseRawHtml={parseRawHtml}268 parseRawHtml={parseRawHtml}
...@@ -148,6 +277,7 @@ describe("ChatMarkdown Windows file links", () => {...@@ -148,6 +277,7 @@ describe("ChatMarkdown Windows file links", () => {
148 const html = renderToStaticMarkup(277 const html = renderToStaticMarkup(
149 <ChatMarkdown278 <ChatMarkdown
150 cwd="C:/Users/shawn/project"279 cwd="C:/Users/shawn/project"
280 environmentId={environmentId}
151 text="[unsafe](javascript:alert(1)) and [unknown](d:alert(1))"281 text="[unsafe](javascript:alert(1)) and [unknown](d:alert(1))"
152 lineBreaks={!parseRawHtml}282 lineBreaks={!parseRawHtml}
153 parseRawHtml={parseRawHtml}283 parseRawHtml={parseRawHtml}
apps/web/src/components/ChatMarkdown.tsx+282-54
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 WrapTextIcon,14 WrapTextIcon,
15} from "lucide-react";15} from "lucide-react";
16import type {16import type {
17 EnvironmentId,
17 ScopedThreadRef,18 ScopedThreadRef,
18 ServerProviderSkill,19 ServerProviderSkill,
19 ThreadLinkedPullRequest,20 ThreadLinkedPullRequest,
...@@ -52,6 +53,10 @@ import { remarkGithubAlerts } from "../markdown-github-alerts";...@@ -52,6 +53,10 @@ import { remarkGithubAlerts } from "../markdown-github-alerts";
52import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";53import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
53import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";54import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
54import { PierreEntryIcon } from "./chat/PierreEntryIcon";55import { PierreEntryIcon } from "./chat/PierreEntryIcon";
56import {
57 revealInFileExplorerLabelForKind,
58 revealInFileExplorerLabelForOs,
59} from "./preview/fileExplorerLabel";
55import {60import {
56 resolveExternalWebLinkHost,61 resolveExternalWebLinkHost,
57 showExternalLinkContextMenu,62 showExternalLinkContextMenu,
...@@ -64,7 +69,12 @@ import { ScrollArea } from "./ui/scroll-area";...@@ -64,7 +69,12 @@ import { ScrollArea } from "./ui/scroll-area";
64import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";69import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
65import { stackedThreadToast, toastManager } from "./ui/toast";70import { stackedThreadToast, toastManager } from "./ui/toast";
66import { recordVisitForThread } from "../browserHistoryStore";71import { recordVisitForThread } from "../browserHistoryStore";
67import { useOpenInPreferredEditor } from "../editorPreferences";72import {
73 PreferredEditorEnvironmentRequiredError,
74 useOpenInPreferredEditor,
75 usePreferredEditor,
76} from "../editorPreferences";
77import { openInEditorMenuLabel } from "../editorLabels";
68import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";78import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
69import { fnv1a32 } from "../lib/diffRendering";79import { fnv1a32 } from "../lib/diffRendering";
70import { LRUCache } from "../lib/lruCache";80import { LRUCache } from "../lib/lruCache";
...@@ -91,9 +101,11 @@ import {...@@ -91,9 +101,11 @@ import {
91import { readLocalApi } from "../localApi";101import { readLocalApi } from "../localApi";
92import { useAssetUrlState } from "../assets/assetUrls";102import { useAssetUrlState } from "../assets/assetUrls";
93import { cn } from "../lib/utils";103import { cn } from "../lib/utils";
104import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";
94import { useRightPanelStore } from "../rightPanelStore";105import { useRightPanelStore } from "../rightPanelStore";
95import { readThreadShell, useActiveEnvironmentId, useProjects } from "../state/entities";106import { readThreadShell, useProjects } from "../state/entities";
96import { serverEnvironment } from "../state/server";107import { serverEnvironment } from "../state/server";
108import { shellEnvironment } from "../state/shell";
97import { assetEnvironment } from "../state/assets";109import { assetEnvironment } from "../state/assets";
98import { usePreparedConnection } from "../state/session";110import { usePreparedConnection } from "../state/session";
99import { previewEnvironment } from "../state/preview";111import { previewEnvironment } from "../state/preview";
...@@ -115,6 +127,7 @@ import {...@@ -115,6 +127,7 @@ import {
115} from "~/lib/openPullRequestLink";127} from "~/lib/openPullRequestLink";
116import { writeTextToClipboard } from "../hooks/useCopyToClipboard";128import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
117import { isPreviewSupportedInRuntime } from "../previewStateStore";129import { isPreviewSupportedInRuntime } from "../previewStateStore";
130import { resolvePathLinkTarget } from "../terminal-links";
118import {131import {
119 isBrowserPreviewFile,132 isBrowserPreviewFile,
120 openFileInPreview,133 openFileInPreview,
...@@ -126,6 +139,8 @@ interface ChatMarkdownProps {...@@ -126,6 +139,8 @@ interface ChatMarkdownProps {
126 text: string;139 text: string;
127 cwd: string | undefined;140 cwd: string | undefined;
128 threadRef?: ScopedThreadRef | undefined;141 threadRef?: ScopedThreadRef | undefined;
142 /** Environment that owns non-thread markdown, such as a pull request panel. */
143 environmentId?: EnvironmentId | undefined;
129 onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;144 onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;
130 isStreaming?: boolean;145 isStreaming?: boolean;
131 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;146 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
...@@ -136,6 +151,35 @@ interface ChatMarkdownProps {...@@ -136,6 +151,35 @@ interface ChatMarkdownProps {
136 parseRawHtml?: boolean;151 parseRawHtml?: boolean;
137}152}
138153
154export function canUseMarkdownFileShellActions(
155 environmentId: EnvironmentId | null,
156 remoteOpenMode: RemoteOpenMode,
157 isRemoteOpenResolved: boolean,
158): boolean {
159 return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec";
160}
161
162export function hasMarkdownFilePrimaryAction(input: {
163 canOpenInEditor: boolean;
164 canOpenInBrowser: boolean;
165 canOpenInPanel: boolean;
166}): boolean {
167 return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel;
168}
169
170export 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
139const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];183const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
140184
141const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;185const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
...@@ -875,14 +919,19 @@ interface MarkdownFileLinkProps {...@@ -875,14 +919,19 @@ interface MarkdownFileLinkProps {
875 copyMarkdown: string;919 copyMarkdown: string;
876 theme: "light" | "dark";920 theme: "light" | "dark";
877 threadRef?: ScopedThreadRef | undefined;921 threadRef?: ScopedThreadRef | undefined;
878 onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;922 onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
879 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;923 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
924 openInEditorMenuLabel: string;
880 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;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 className?: string | undefined;930 className?: string | undefined;
882}931}
883932
884const MARKDOWN_FILE_LINK_CLASS_NAME =933const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link";
885 "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";934const 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`;
886935
887function pathParentSegments(path: string): string[] {936function pathParentSegments(path: string): string[] {
888 const normalized = path.replaceAll("\\", "/");937 const normalized = path.replaceAll("\\", "/");
...@@ -1229,10 +1278,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1229,10 +1278,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1229 threadRef,1278 threadRef,
1230 onOpen,1279 onOpen,
1231 onOpenInPanel,1280 onOpenInPanel,
1281 openInEditorMenuLabel,
1232 onOpenInBrowser,1282 onOpenInBrowser,
1283 onReveal,
1284 revealLabel,
1233 className,1285 className,
1234}: MarkdownFileLinkProps) {1286}: MarkdownFileLinkProps) {
1235 const handleOpenInEditor = useCallback(() => {1287 const handleOpenInEditor = useCallback(() => {
1288 if (!onOpen) {
1289 return;
1290 }
1236 void (async () => {1291 void (async () => {
1237 try {1292 try {
1238 const result = await onOpen(targetPath);1293 const result = await onOpen(targetPath);
...@@ -1313,6 +1368,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1313,6 +1368,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1313 })();1368 })();
1314 }, [onOpenInBrowser, targetPath]);1369 }, [onOpenInBrowser, targetPath]);
13151370
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 const handleCopy = useCallback(1409 const handleCopy = useCallback(
1317 (value: string, title: string) => {1410 (value: string, title: string) => {
1318 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {1411 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
...@@ -1352,25 +1445,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1352,25 +1445,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1352 [targetPath],1445 [targetPath],
1353 );1446 );
13541447
1355 const handleContextMenu = useCallback(1448 const showFileContextMenu = useCallback(
1356 async (event: ReactMouseEvent<HTMLAnchorElement>) => {1449 async (position: { x: number; y: number }) => {
1357 event.preventDefault();
1358 event.stopPropagation();
1359
1360 const api = readLocalApi();1450 const api = readLocalApi();
1361 if (!api) return;1451 if (!api) return;
13621452
1363 try {1453 try {
1364 const clicked = await api.contextMenu.show(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 ...(onOpenInBrowser1457 ...(onOpenInBrowser
1368 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)1458 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
1369 : []),1459 : []),
1460 ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []),
1370 { id: "copy-relative", label: "Copy relative path" },1461 { id: "copy-relative", label: "Copy relative path" },
1371 { id: "copy-full", label: "Copy full path" },1462 { id: "copy-full", label: "Copy full path" },
1372 ] as const,1463 ] as const,
1373 { x: event.clientX, y: event.clientY },1464 position,
1374 );1465 );
13751466
1376 if (clicked === "open") {1467 if (clicked === "open") {
...@@ -1381,6 +1472,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1381,6 +1472,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1381 handleOpenInBrowser();1472 handleOpenInBrowser();
1382 return;1473 return;
1383 }1474 }
1475 if (clicked === "reveal") {
1476 handleRevealInFileManager();
1477 return;
1478 }
1384 if (clicked === "copy-relative") {1479 if (clicked === "copy-relative") {
1385 handleCopy(displayPath, "Relative path");1480 handleCopy(displayPath, "Relative path");
1386 return;1481 return;
...@@ -1395,34 +1490,100 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -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 );
14001507
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 return (1539 return (
1402 <Tooltip>1540 <Tooltip>
1403 <TooltipTrigger1541 <TooltipTrigger
1404 render={1542 render={
1405 <a1543 hasPrimaryAction ? (
1406 href={href}1544 <a
1407 className={cn(CHAT_FILE_TAG_CHIP_CLASS_NAME, MARKDOWN_FILE_LINK_CLASS_NAME, className)}1545 href={href}
1408 data-markdown-copy={copyMarkdown}1546 className={cn(
1409 onClick={(event) => {1547 CHAT_FILE_TAG_CHIP_CLASS_NAME,
1410 event.preventDefault();1548 MARKDOWN_FILE_LINK_CLASS_NAME,
1411 event.stopPropagation();1549 className,
1412 if (shouldOpenMarkdownFileLinkInEditor(event)) {1550 )}
1413 handleOpenInEditor();1551 data-markdown-copy={copyMarkdown}
1414 return;1552 onClick={(event) => {
1415 }1553 event.preventDefault();
1416 if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) {1554 event.stopPropagation();
1417 handleOpenInBrowser();1555 if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) {
1418 return;1556 handleOpenInEditor();
1419 }1557 return;
1420 handleOpenInFilePreview();1558 }
1421 }}1559 if (useBrowserPrimaryAction) {
1422 onContextMenu={handleContextMenu}1560 handleOpenInBrowser();
1423 >1561 return;
1424 <FileTagChipContent path={iconPath} label={label} theme={theme} selectable />1562 }
1425 </a>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 <TooltipPopup1589 <TooltipPopup
...@@ -1456,7 +1617,10 @@ function areMarkdownFileLinkPropsEqual(...@@ -1456,7 +1617,10 @@ function areMarkdownFileLinkPropsEqual(
1456 previous.threadRef === next.threadRef &&1617 previous.threadRef === next.threadRef &&
1457 previous.onOpen === next.onOpen &&1618 previous.onOpen === next.onOpen &&
1458 previous.onOpenInPanel === next.onOpenInPanel &&1619 previous.onOpenInPanel === next.onOpenInPanel &&
1620 previous.openInEditorMenuLabel === next.openInEditorMenuLabel &&
1459 previous.onOpenInBrowser === next.onOpenInBrowser &&1621 previous.onOpenInBrowser === next.onOpenInBrowser &&
1622 previous.onReveal === next.onReveal &&
1623 previous.revealLabel === next.revealLabel &&
1460 previous.className === next.className1624 previous.className === next.className
1461 );1625 );
1462}1626}
...@@ -1465,6 +1629,7 @@ function ChatMarkdown({...@@ -1465,6 +1629,7 @@ function ChatMarkdown({
1465 text,1629 text,
1466 cwd,1630 cwd,
1467 threadRef,1631 threadRef,
1632 environmentId: explicitEnvironmentId,
1468 onTaskListChange,1633 onTaskListChange,
1469 isStreaming = false,1634 isStreaming = false,
1470 skills = EMPTY_MARKDOWN_SKILLS,1635 skills = EMPTY_MARKDOWN_SKILLS,
...@@ -1485,16 +1650,49 @@ function ChatMarkdown({...@@ -1485,16 +1650,49 @@ function ChatMarkdown({
1485 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1650 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1486 reportFailure: false,1651 reportFailure: false,
1487 });1652 });
1488 const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null);1653 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
1489 const environmentId = useActiveEnvironmentId();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 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));1661 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
1491 const threadServerConfig = useAtomValue(1662 const threadServerConfig = useAtomValue(
1492 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),1663 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
1493 );1664 );
1494 const projects = useProjects();1665 const projects = useProjects();
1495 const openInPreferredEditor = useOpenInPreferredEditor(1666 const availableEditors = serverConfig?.availableEditors ?? [];
1496 environmentId,1667 const [preferredEditor] = usePreferredEditor(availableEditors);
1497 serverConfig?.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 const diffThemeName = resolveDiffThemeName(resolvedTheme);1697 const diffThemeName = resolveDiffThemeName(resolvedTheme);
1500 const markdownFileLinkMetaByHref = useMemo(() => {1698 const markdownFileLinkMetaByHref = useMemo(() => {
...@@ -1636,6 +1834,26 @@ function ChatMarkdown({...@@ -1636,6 +1834,26 @@ function ChatMarkdown({
1636 },1834 },
1637 [createAssetUrl, openPreview, preparedConnection, threadRef],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 // A bare filename resolves to the workspace root, which is rarely where the1857 // A bare filename resolves to the workspace root, which is rarely where the
1640 // file is, so ask the index before opening.1858 // file is, so ask the index before opening.
1641 const openFileInPanel = useCallback(1859 const openFileInPanel = useCallback(
...@@ -1651,24 +1869,23 @@ function ChatMarkdown({...@@ -1651,24 +1869,23 @@ function ChatMarkdown({
1651 return;1869 return;
1652 }1870 }
1653 void (async () => {1871 void (async () => {
1654 const result = await searchProjectEntries({1872 const match = await findWorkspaceBasenameMatch(workspaceRelativePath);
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;
1667 if (!isLatestLookup()) return;1873 if (!isLatestLookup()) return;
1668 openAt(match ?? workspaceRelativePath);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 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component1890 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component
1674 * renderers that close over this message's metadata. useMemo keeps them stable until that1891 * renderers that close over this message's metadata. useMemo keeps them stable until that
...@@ -1704,8 +1921,15 @@ function ChatMarkdown({...@@ -1704,8 +1921,15 @@ function ChatMarkdown({
1704 copyMarkdown={copyMarkdown}1921 copyMarkdown={copyMarkdown}
1705 theme={resolvedTheme}1922 theme={resolvedTheme}
1706 threadRef={threadRef}1923 threadRef={threadRef}
1707 onOpen={openInPreferredEditor}1924 {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})}
1708 onOpenInPanel={openFileInPanel}1925 onOpenInPanel={openFileInPanel}
1926 openInEditorMenuLabel={preferredEditorMenuLabel}
1927 onReveal={
1928 canUseShellActions && revealInFileManagerLabel !== undefined
1929 ? () => revealMarkdownFileInFileManager(fileLinkMeta)
1930 : undefined
1931 }
1932 revealLabel={revealInFileManagerLabel}
1709 onOpenInBrowser={1933 onOpenInBrowser={
1710 threadRef &&1934 threadRef &&
1711 isPreviewSupportedInRuntime() &&1935 isPreviewSupportedInRuntime() &&
...@@ -1984,6 +2208,7 @@ function ChatMarkdown({...@@ -1984,6 +2208,7 @@ function ChatMarkdown({
1984 },2208 },
1985 };2209 };
1986 }, [2210 }, [
2211 canUseShellActions,
1987 cwd,2212 cwd,
1988 diffThemeName,2213 diffThemeName,
1989 fileLinkParentSuffixByPath,2214 fileLinkParentSuffixByPath,
...@@ -1996,8 +2221,11 @@ function ChatMarkdown({...@@ -1996,8 +2221,11 @@ function ChatMarkdown({
1996 openChangeRequestLink,2221 openChangeRequestLink,
1997 openExternalLinkInPreview,2222 openExternalLinkInPreview,
1998 openMarkdownFileInPreview,2223 openMarkdownFileInPreview,
2224 preferredEditorMenuLabel,
1999 resolveThreadPullRequest,2225 resolveThreadPullRequest,
2000 resolvedTheme,2226 resolvedTheme,
2227 revealMarkdownFileInFileManager,
2228 revealInFileManagerLabel,
2001 skills,2229 skills,
2002 text,2230 text,
2003 threadRef,2231 threadRef,
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+7-2
...@@ -25,10 +25,15 @@ vi.mock("../state/session", async (importOriginal) => ({...@@ -25,10 +25,15 @@ vi.mock("../state/session", async (importOriginal) => ({
25}));25}));
26vi.mock("../state/entities", () => ({26vi.mock("../state/entities", () => ({
27 readThreadShell: () => null,27 readThreadShell: () => null,
28 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
29 useProjects: () => [],28 useProjects: () => [],
30}));29}));
31vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() }));30vi.mock("../remoteOpen", () => ({
31 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
32}));
33vi.mock("../editorPreferences", () => ({
34 useOpenInPreferredEditor: () => vi.fn(),
35 usePreferredEditor: () => [null, vi.fn()],
36}));
32vi.mock("~/lib/openPullRequestLink", () => ({37vi.mock("~/lib/openPullRequestLink", () => ({
33 findProjectForChangeRequest: () => undefined,38 findProjectForChangeRequest: () => undefined,
34 matchesLinkedPullRequestUrl: () => false,39 matchesLinkedPullRequestUrl: () => false,
apps/web/src/components/chat/OpenInPicker.tsx+6-28
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7import { memo, useCallback, useEffect, useMemo } from "react";7import { memo, useCallback, useEffect, useMemo } from "react";
8import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";8import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
9import { usePreferredEditor } from "../../editorPreferences";9import { usePreferredEditor } from "../../editorPreferences";
10import { editorLabelForPlatform } from "../../editorLabels";
10import {11import {
11 openRemoteEditorUrl,12 openRemoteEditorUrl,
12 useRemoteCapableEditors,13 useRemoteCapableEditors,
...@@ -43,7 +44,7 @@ import {...@@ -43,7 +44,7 @@ import {
43 RustRoverIcon,44 RustRoverIcon,
44 WebStormIcon,45 WebStormIcon,
45} from "../JetBrainsIcons";46} from "../JetBrainsIcons";
46import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";47import { cn } from "~/lib/utils";
47import { shellEnvironment } from "~/state/shell";48import { shellEnvironment } from "~/state/shell";
48import { useAtomCommand } from "~/state/use-atom-command";49import { useAtomCommand } from "~/state/use-atom-command";
4950
...@@ -55,140 +56,117 @@ type OpenInOption = {...@@ -55,140 +56,117 @@ type OpenInOption = {
55};56};
5657
57const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {58const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {
58 const baseOptions: ReadonlyArray<OpenInOption> = [59 const baseOptions: ReadonlyArray<Omit<OpenInOption, "label">> = [
59 {60 {
60 label: "Cursor",
61 Icon: CursorIcon,61 Icon: CursorIcon,
62 value: "cursor",62 value: "cursor",
63 kind: "brand",63 kind: "brand",
64 },64 },
65 {65 {
66 label: "Trae",
67 Icon: TraeIcon,66 Icon: TraeIcon,
68 value: "trae",67 value: "trae",
69 kind: "brand",68 kind: "brand",
70 },69 },
71 {70 {
72 label: "Kiro",
73 Icon: KiroIcon,71 Icon: KiroIcon,
74 value: "kiro",72 value: "kiro",
75 kind: "brand",73 kind: "brand",
76 },74 },
77 {75 {
78 label: "VS Code",
79 Icon: VisualStudioCode,76 Icon: VisualStudioCode,
80 value: "vscode",77 value: "vscode",
81 kind: "brand",78 kind: "brand",
82 },79 },
83 {80 {
84 label: "VS Code Insiders",
85 Icon: VisualStudioCodeInsiders,81 Icon: VisualStudioCodeInsiders,
86 value: "vscode-insiders",82 value: "vscode-insiders",
87 kind: "brand",83 kind: "brand",
88 },84 },
89 {85 {
90 label: "VSCodium",
91 Icon: VSCodium,86 Icon: VSCodium,
92 value: "vscodium",87 value: "vscodium",
93 kind: "brand",88 kind: "brand",
94 },89 },
95 {90 {
96 label: "Zed",
97 Icon: Zed,91 Icon: Zed,
98 value: "zed",92 value: "zed",
99 kind: "brand",93 kind: "brand",
100 },94 },
101 {95 {
102 label: "Antigravity",
103 Icon: AntigravityIcon,96 Icon: AntigravityIcon,
104 value: "antigravity",97 value: "antigravity",
105 kind: "brand",98 kind: "brand",
106 },99 },
107 {100 {
108 label: "IntelliJ IDEA",
109 Icon: IntelliJIdeaIcon,101 Icon: IntelliJIdeaIcon,
110 value: "idea",102 value: "idea",
111 kind: "brand",103 kind: "brand",
112 },104 },
113 {105 {
114 label: "Aqua",
115 Icon: AquaIcon,106 Icon: AquaIcon,
116 value: "aqua",107 value: "aqua",
117 kind: "brand",108 kind: "brand",
118 },109 },
119 {110 {
120 label: "CLion",
121 Icon: CLionIcon,111 Icon: CLionIcon,
122 value: "clion",112 value: "clion",
123 kind: "brand",113 kind: "brand",
124 },114 },
125 {115 {
126 label: "DataGrip",
127 Icon: DataGripIcon,116 Icon: DataGripIcon,
128 value: "datagrip",117 value: "datagrip",
129 kind: "brand",118 kind: "brand",
130 },119 },
131 {120 {
132 label: "DataSpell",
133 Icon: DataSpellIcon,121 Icon: DataSpellIcon,
134 value: "dataspell",122 value: "dataspell",
135 kind: "brand",123 kind: "brand",
136 },124 },
137 {125 {
138 label: "GoLand",
139 Icon: GoLandIcon,126 Icon: GoLandIcon,
140 value: "goland",127 value: "goland",
141 kind: "brand",128 kind: "brand",
142 },129 },
143 {130 {
144 label: "PhpStorm",
145 Icon: PhpStormIcon,131 Icon: PhpStormIcon,
146 value: "phpstorm",132 value: "phpstorm",
147 kind: "brand",133 kind: "brand",
148 },134 },
149 {135 {
150 label: "PyCharm",
151 Icon: PyCharmIcon,136 Icon: PyCharmIcon,
152 value: "pycharm",137 value: "pycharm",
153 kind: "brand",138 kind: "brand",
154 },139 },
155 {140 {
156 label: "Rider",
157 Icon: RiderIcon,141 Icon: RiderIcon,
158 value: "rider",142 value: "rider",
159 kind: "brand",143 kind: "brand",
160 },144 },
161 {145 {
162 label: "RubyMine",
163 Icon: RubyMineIcon,146 Icon: RubyMineIcon,
164 value: "rubymine",147 value: "rubymine",
165 kind: "brand",148 kind: "brand",
166 },149 },
167 {150 {
168 label: "RustRover",
169 Icon: RustRoverIcon,151 Icon: RustRoverIcon,
170 value: "rustrover",152 value: "rustrover",
171 kind: "brand",153 kind: "brand",
172 },154 },
173 {155 {
174 label: "WebStorm",
175 Icon: WebStormIcon,156 Icon: WebStormIcon,
176 value: "webstorm",157 value: "webstorm",
177 kind: "brand",158 kind: "brand",
178 },159 },
179 {160 {
180 label: isMacPlatform(platform)
181 ? "Finder"
182 : isWindowsPlatform(platform)
183 ? "Explorer"
184 : "Files",
185 Icon: FolderClosedIcon,161 Icon: FolderClosedIcon,
186 value: "file-manager",162 value: "file-manager",
187 kind: "generic",163 kind: "generic",
188 },164 },
189 ];165 ];
190 const availableEditorSet = new Set(availableEditors);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};
193171
194function getOpenInIconClass(kind: OpenInOption["kind"]) {172function getOpenInIconClass(kind: OpenInOption["kind"]) {
apps/web/src/components/preview/fileExplorerLabel.test.ts+26-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { revealInFileExplorerLabel } from "./fileExplorerLabel";3import {
4 revealInFileExplorerLabel,
5 revealInFileExplorerLabelForKind,
6 revealInFileExplorerLabelForOs,
7} from "./fileExplorerLabel";
48
5describe("revealInFileExplorerLabel", () => {9describe("revealInFileExplorerLabel", () => {
6 it.each([10 it.each([
...@@ -11,3 +15,24 @@ describe("revealInFileExplorerLabel", () => {...@@ -11,3 +15,24 @@ describe("revealInFileExplorerLabel", () => {
11 expect(revealInFileExplorerLabel(platform)).toBe(expected);15 expect(revealInFileExplorerLabel(platform)).toBe(expected);
12 });16 });
13});17});
18
19describe("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
30describe("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,6 +1,23 @@
1import type { ExecutionEnvironmentPlatformOs, FileManagerRevealKind } from "@t3tools/contracts";
2
1export function revealInFileExplorerLabel(platform: string): string {3export function revealInFileExplorerLabel(platform: string): string {
2 const normalized = platform.toLowerCase();4 const normalized = platform.toLowerCase();
3 if (normalized.includes("mac")) return "Reveal in Finder";5 if (normalized.includes("mac")) return "Reveal in Finder";
4 if (normalized.includes("win")) return "Reveal in File Explorer";6 if (normalized.includes("win")) return "Reveal in File Explorer";
5 return "Reveal in Files";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. */
12export 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. */
19export 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,4 +1,5 @@
1import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";1import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";
2import type { EnvironmentId } from "@t3tools/contracts";
23
3import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
45
...@@ -19,10 +20,12 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic";...@@ -19,10 +20,12 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic";
19export function PullRequestMarkdown({20export function PullRequestMarkdown({
20 text,21 text,
21 cwd,22 cwd,
23 environmentId,
22 className,24 className,
23}: {25}: {
24 text: string;26 text: string;
25 cwd: string;27 cwd: string;
28 environmentId: EnvironmentId;
26 className?: string;29 className?: string;
27}) {30}) {
28 const segments = splitPullRequestBody(text);31 const segments = splitPullRequestBody(text);
...@@ -30,7 +33,14 @@ export function PullRequestMarkdown({...@@ -30,7 +33,14 @@ export function PullRequestMarkdown({
30 <div className={cn("space-y-3", className)}>33 <div className={cn("space-y-3", className)}>
31 {segments.map((segment) => {34 {segments.map((segment) => {
32 if (segment.kind === "markdown") {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 const isVideo = segment.media === "video";45 const isVideo = segment.media === "video";
36 const Icon = isVideo ? PlayIcon : PaperclipIcon;46 const Icon = isVideo ? PlayIcon : PaperclipIcon;
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx+4-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useState } from "react";1import { useState } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
23
3import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
45
...@@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown";...@@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown";
17export function PullRequestMarkdownEditor({18export function PullRequestMarkdownEditor({
18 value,19 value,
19 cwd,20 cwd,
21 environmentId,
20 placeholder,22 placeholder,
21 label,23 label,
22 saving,24 saving,
...@@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({...@@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({
27}: {29}: {
28 readonly value: string;30 readonly value: string;
29 readonly cwd: string;31 readonly cwd: string;
32 readonly environmentId: EnvironmentId;
30 readonly placeholder?: string | undefined;33 readonly placeholder?: string | undefined;
31 readonly label: string;34 readonly label: string;
32 readonly saving: boolean;35 readonly saving: boolean;
...@@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({...@@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({
81 {empty ? (84 {empty ? (
82 <p className="text-xs text-muted-foreground">Nothing to preview.</p>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 </div>89 </div>
87 ) : (90 ) : (
apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx+2
...@@ -273,6 +273,7 @@ export function ReviewThreadCard({...@@ -273,6 +273,7 @@ export function ReviewThreadCard({
273 className="mt-1"273 className="mt-1"
274 value={comment.body}274 value={comment.body}
275 cwd={workspaceRoot}275 cwd={workspaceRoot}
276 environmentId={environmentId}
276 label="Edit comment"277 label="Edit comment"
277 saving={savingEdit}278 saving={savingEdit}
278 onSave={(body) => void saveEdit(comment.id, body)}279 onSave={(body) => void saveEdit(comment.id, body)}
...@@ -284,6 +285,7 @@ export function ReviewThreadCard({...@@ -284,6 +285,7 @@ export function ReviewThreadCard({
284 className="min-w-0 flex-1 text-sm"285 className="min-w-0 flex-1 text-sm"
285 text={comment.body}286 text={comment.body}
286 cwd={workspaceRoot}287 cwd={workspaceRoot}
288 environmentId={environmentId}
287 />289 />
288 {canEditComment(comment) ? (290 {canEditComment(comment) ? (
289 <Button291 <Button
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+11-1
...@@ -94,6 +94,7 @@ function reviewStateLabel(state: string): string {...@@ -94,6 +94,7 @@ function reviewStateLabel(state: string): string {
94/** What every remark in the conversation needs to be rewritten where it sits. */94/** What every remark in the conversation needs to be rewritten where it sits. */
95interface CommentEditing {95interface CommentEditing {
96 readonly cwd: string;96 readonly cwd: string;
97 readonly environmentId: EnvironmentId;
97 readonly canEdit: (comment: PullRequestComment) => boolean;98 readonly canEdit: (comment: PullRequestComment) => boolean;
98 readonly editingId: string | null;99 readonly editingId: string | null;
99 readonly saving: boolean;100 readonly saving: boolean;
...@@ -120,6 +121,7 @@ function CommentBody({...@@ -120,6 +121,7 @@ function CommentBody({
120 className={className}121 className={className}
121 value={comment.body}122 value={comment.body}
122 cwd={editing.cwd}123 cwd={editing.cwd}
124 environmentId={editing.environmentId}
123 label="Edit comment"125 label="Edit comment"
124 saving={editing.saving}126 saving={editing.saving}
125 onSave={(body) => editing.onSave(comment, body)}127 onSave={(body) => editing.onSave(comment, body)}
...@@ -129,7 +131,12 @@ function CommentBody({...@@ -129,7 +131,12 @@ function CommentBody({
129 }131 }
130 return (132 return (
131 <div className={cn("flex items-start gap-1", className)}>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 {editing.canEdit(comment) ? (140 {editing.canEdit(comment) ? (
134 <Button141 <Button
135 size="icon-xs"142 size="icon-xs"
...@@ -489,6 +496,7 @@ export function PullRequestSummaryTab({...@@ -489,6 +496,7 @@ export function PullRequestSummaryTab({
489496
490 const commentEditing: CommentEditing = {497 const commentEditing: CommentEditing = {
491 cwd: detail.workspaceRoot,498 cwd: detail.workspaceRoot,
499 environmentId,
492 canEdit: (comment) => canEditPullRequestComment(detail, comment),500 canEdit: (comment) => canEditPullRequestComment(detail, comment),
493 editingId: editingCommentId,501 editingId: editingCommentId,
494 saving: commentSaving,502 saving: commentSaving,
...@@ -649,6 +657,7 @@ export function PullRequestSummaryTab({...@@ -649,6 +657,7 @@ export function PullRequestSummaryTab({
649 allowEmpty657 allowEmpty
650 value={detail.body}658 value={detail.body}
651 cwd={detail.workspaceRoot}659 cwd={detail.workspaceRoot}
660 environmentId={environmentId}
652 label="Pull request description"661 label="Pull request description"
653 placeholder="Describe this pull request"662 placeholder="Describe this pull request"
654 saving={bodySaving}663 saving={bodySaving}
...@@ -661,6 +670,7 @@ export function PullRequestSummaryTab({...@@ -661,6 +670,7 @@ export function PullRequestSummaryTab({
661 className="min-w-0 flex-1"670 className="min-w-0 flex-1"
662 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}671 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}
663 cwd={detail.workspaceRoot}672 cwd={detail.workspaceRoot}
673 environmentId={environmentId}
664 />674 />
665 {canEditPullRequestChangeRequest(detail) ? (675 {canEditPullRequestChangeRequest(detail) ? (
666 <Button676 <Button
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+25-4
...@@ -59,11 +59,21 @@ interface ReactionSurface {...@@ -59,11 +59,21 @@ interface ReactionSurface {
59 readonly onRefresh: () => void;59 readonly onRefresh: () => void;
60}60}
6161
62function TimelineBody({ body, markdown, cwd }: { body: string; markdown: boolean; cwd: string }) {62function TimelineBody({
63 body,
64 markdown,
65 cwd,
66 environmentId,
67}: {
68 body: string;
69 markdown: boolean;
70 cwd: string;
71 environmentId: EnvironmentId;
72}) {
63 return (73 return (
64 <div className="mt-3">74 <div className="mt-3">
65 {markdown ? (75 {markdown ? (
66 <PullRequestMarkdown text={body} cwd={cwd} />76 <PullRequestMarkdown text={body} cwd={cwd} environmentId={environmentId} />
67 ) : (77 ) : (
68 <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p>78 <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p>
69 )}79 )}
...@@ -235,6 +245,7 @@ function ConversationCard({...@@ -235,6 +245,7 @@ function ConversationCard({
235 <PullRequestMarkdownEditor245 <PullRequestMarkdownEditor
236 value={editable.body}246 value={editable.body}
237 cwd={cwd}247 cwd={cwd}
248 environmentId={reactions.environmentId}
238 label="Edit comment"249 label="Edit comment"
239 saving={saving}250 saving={saving}
240 onSave={(body) => void save(body)}251 onSave={(body) => void save(body)}
...@@ -243,7 +254,12 @@ function ConversationCard({...@@ -243,7 +254,12 @@ function ConversationCard({
243 </div>254 </div>
244 ) : event.body ? (255 ) : event.body ? (
245 <div className="px-2 pb-2">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 </div>263 </div>
248 ) : null}264 ) : null}
249 {reactions.canReact || event.reactions.length > 0 ? (265 {reactions.canReact || event.reactions.length > 0 ? (
...@@ -505,7 +521,12 @@ function ReviewVerdictEvent({...@@ -505,7 +521,12 @@ function ReviewVerdictEvent({
505 {/* An approval usually carries no words. When it does they are the review, so they stay521 {/* An approval usually carries no words. When it does they are the review, so they stay
506 visible rather than being folded away with the ordinary conversation. */}522 visible rather than being folded away with the ordinary conversation. */}
507 {event.body ? (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 ) : null}530 ) : null}
510 </div>531 </div>
511 <OpenOnHostButton url={event.url} onOpen={onOpen} />532 <OpenOnHostButton url={event.url} onOpen={onOpen} />
apps/web/src/editorLabels.test.ts created+29
...@@ -0,0 +1,29 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { editorLabelForPlatform, openInEditorMenuLabel } from "./editorLabels";
4
5describe("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
20describe("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 @@
1import { EDITORS, type EditorId } from "@t3tools/contracts";
2
3import { getLocalFileManagerName } from "~/lib/utils";
4
5const editorLabels = new Map<EditorId, string>(EDITORS.map((editor) => [editor.id, editor.label]));
6
7export 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
15export 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,13 +2271,13 @@ code {
2271 font-size: 0.75rem;2271 font-size: 0.75rem;
2272}2272}
22732273
2274.chat-markdown a.chat-markdown-file-link,2274.chat-markdown .chat-markdown-file-link,
2275.chat-markdown a.chat-markdown-file-link:hover {2275.chat-markdown .chat-markdown-file-link:hover {
2276 color: var(--contrast-foreground);2276 color: var(--contrast-foreground);
2277 text-decoration: none;2277 text-decoration: none;
2278}2278}
22792279
2280.chat-markdown a.chat-markdown-file-link:focus-visible {2280.chat-markdown .chat-markdown-file-link:focus-visible {
2281 outline: none;2281 outline: none;
2282 box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 70%, transparent);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,8 +36,17 @@ export type RemoteOpenState =
3636
37export type RemoteOpenMode = RemoteOpenState["mode"];37export type RemoteOpenMode = RemoteOpenState["mode"];
3838
39export interface RemoteOpenResolution {
40 readonly state: RemoteOpenState;
41 readonly isResolved: boolean;
42}
43
39const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" };44const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" };
40const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" };45const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" };
46const UNRESOLVED_REMOTE_OPEN: RemoteOpenResolution = {
47 state: LOCAL_EXEC,
48 isResolved: false,
49};
4150
42function parseHostname(url: string): string | null {51function parseHostname(url: string): string | null {
43 try {52 try {
...@@ -87,25 +96,32 @@ export function resolveRemoteOpenState(input: {...@@ -87,25 +96,32 @@ export function resolveRemoteOpenState(input: {
87 return REMOTE_UNAVAILABLE;96 return REMOTE_UNAVAILABLE;
88}97}
8998
90export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {99export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution {
91 const { presentation } = useEnvironmentPresentation(environmentId);100 const { presentation } = useEnvironmentPresentation(environmentId);
92101
93 return useMemo(() => {102 return useMemo(() => {
94 if (presentation === null) {103 if (presentation === null) {
95 return LOCAL_EXEC;104 return UNRESOLVED_REMOTE_OPEN;
96 }105 }
97 const profile = Option.getOrNull(presentation.entry.profile);106 const profile = Option.getOrNull(presentation.entry.profile);
98 const sshAlias =107 const sshAlias =
99 profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null;108 profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null;
100 return resolveRemoteOpenState({109 return {
101 target: presentation.entry.target,110 state: resolveRemoteOpenState({
102 sshAlias,111 target: presentation.entry.target,
103 remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,112 sshAlias,
104 isDesktopRenderer: window.desktopBridge !== undefined,113 remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,
105 });114 isDesktopRenderer: window.desktopBridge !== undefined,
115 }),
116 isResolved: true,
117 };
106 }, [presentation]);118 }, [presentation]);
107}119}
108120
121export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
122 return useRemoteOpenResolution(environmentId).state;
123}
124
109/**125/**
110 * Editors offered in remote-link mode. The desktop app probes the machine the126 * Editors offered in remote-link mode. The desktop app probes the machine the
111 * renderer runs on; a browser cannot, so it offers VS Code only.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,9 +69,16 @@ export const EDITORS = [
69export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));69export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
70export type EditorId = typeof EditorId.Type;70export type EditorId = typeof EditorId.Type;
7171
72export const FileManagerRevealKind = Schema.Literals(["finder", "file-explorer", "files"]);
73export type FileManagerRevealKind = typeof FileManagerRevealKind.Type;
74
72export const LaunchEditorInput = Schema.Struct({75export const LaunchEditorInput = Schema.Struct({
73 cwd: TrimmedNonEmptyString,76 cwd: TrimmedNonEmptyString,
74 editor: EditorId,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});
76export type LaunchEditorInput = typeof LaunchEditorInput.Type;83export type LaunchEditorInput = typeof LaunchEditorInput.Type;
7784
packages/contracts/src/server.ts+6-1
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 KeybindingWhen,17 KeybindingWhen,
18 ResolvedKeybindingsConfig,18 ResolvedKeybindingsConfig,
19} from "./keybindings.ts";19} from "./keybindings.ts";
20import { EditorId, RemoteOpenTarget } from "./editor.ts";20import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts";
21import { ModelCapabilities } from "./model.ts";21import { ModelCapabilities } from "./model.ts";
22import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";22import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
23import { ServerSettings } from "./settings.ts";23import { ServerSettings } from "./settings.ts";
...@@ -438,6 +438,11 @@ export const ServerConfig = Schema.Struct({...@@ -438,6 +438,11 @@ export const ServerConfig = Schema.Struct({
438 settings: ServerSettings,438 settings: ServerSettings,
439 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */439 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
440 shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),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 /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */446 /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */
442 threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),447 threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),
443 /**448 /**