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
7070- 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.
7171- 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
7380## Change discipline
7481
7582- Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup.
apps/server/src/process/externalLauncher.test.ts+643-5
......@@ -1,3 +1,8 @@
1// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs.
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";
16import * as NodeServices from "@effect/platform-node/NodeServices";
27import { assert, it } from "@effect/vitest";
38import * as ConfigProvider from "effect/ConfigProvider";
......@@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1520import { SpawnExecutableResolution } from "@t3tools/shared/shell";
1621import * 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 } = {}) {
1931 return ChildProcessSpawner.makeHandle({
2032 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)),
2236 isRunning: Effect.succeed(true),
2337 kill: () => Effect.void,
2438 unref: Effect.sync(() => {
25 onUnref();
39 input.onUnref?.();
2640 return Effect.void;
2741 }),
2842 stdin: Sink.drain,
29 stdout: Stream.empty,
43 stdout:
44 input.stdout === undefined
45 ? Stream.empty
46 : Stream.make(new TextEncoder().encode(input.stdout)),
3047 stderr: Stream.empty,
3148 all: Stream.empty,
3249 getInputFd: () => Sink.drain,
......@@ -40,6 +57,7 @@ const testLayer = (input: {
4057 readonly resolveExecutable?: (command: string) => string | undefined;
4158 readonly onSpawn?: (command: ChildProcess.StandardCommand) => void;
4259 readonly onUnref?: () => void;
60 readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined;
4361}) => {
4462 const spawnerLayer = Layer.succeed(
4563 ChildProcessSpawner.ChildProcessSpawner,
......@@ -50,7 +68,10 @@ const testLayer = (input: {
5068 throw new Error("Expected a standard command");
5169 }
5270 input.onSpawn?.(command);
53 return makeMockDetachedHandle(input.onUnref);
71 return makeMockDetachedHandle({
72 ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }),
73 ...input.spawnResult?.(command),
74 });
5475 }),
5576 ),
5677 );
......@@ -132,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
132153 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
133154);
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
135773it.effect("discovers editors through the service API", () =>
136774 Effect.gen(function* () {
137775 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/process/externalLauncher.ts+312-19
......@@ -15,6 +15,7 @@ import {
1515 ExternalLauncherUnknownEditorError,
1616 ExternalLauncherUnsupportedEditorError,
1717 type EditorId,
18 type FileManagerRevealKind,
1819 type LaunchEditorInput,
1920} from "@t3tools/contracts";
2021import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
......@@ -29,6 +30,7 @@ import * as Layer from "effect/Layer";
2930import * as Option from "effect/Option";
3031import * as Path from "effect/Path";
3132import * as Ref from "effect/Ref";
33import * as Stream from "effect/Stream";
3234import * as ChildProcess from "effect/unstable/process/ChildProcess";
3335import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
3436
......@@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({
99101 SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option),
100102 SSH_TTY: Config.string("SSH_TTY").pipe(Config.option),
101103 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),
102106}).pipe(Config.map(compactEnv));
103107
104108const CommandLookupEnvConfig = Config.all({
......@@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string {
193197 return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
194198}
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(
197207 platform: NodeJS.Platform,
198208 env: NodeJS.ProcessEnv = {},
199209): boolean {
......@@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa
223233 };
224234}
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 {
227246 switch (platform) {
228247 case "darwin":
229248 return "open";
230249 case "win32":
231250 return "explorer";
232251 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;
234256 }
235257}
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
237393function buildBrowserLaunch(
238394 target: string,
239395 platform: NodeJS.Platform,
......@@ -251,7 +407,7 @@ function buildBrowserLaunch(
251407 return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env));
252408 }
253409
254 if (shouldUseWindowsBrowserFromWsl(platform, env)) {
410 if (shouldUseWindowsHostFromWsl(platform, env)) {
255411 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath());
256412 }
257413
......@@ -265,13 +421,16 @@ function buildBrowserLaunch(
265421const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* (
266422 platform: NodeJS.Platform,
267423 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> {
269429 const available: EditorId[] = [];
270430
271431 for (const editor of EDITORS) {
272432 if (editor.commands === null) {
273 const command = fileManagerCommandForPlatform(platform);
274 if (yield* isCommandAvailable(command, { env })) {
433 if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) {
275434 available.push(editor.id);
276435 }
277436 continue;
......@@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(
296455
297456const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
298457 const platform = yield* HostProcessPlatform;
299 const env = yield* readCommandLookupEnv;
458 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
300459 return yield* buildAvailableEditors(platform, env);
301460});
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
303470// Editor discovery walks PATH for every known editor and runs for every
304471// client connect (the server config embeds the available editors). Memoize
305472// the discovered set for a bounded window so repeat connects skip even the
......@@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service<
329496 ExternalLauncher,
330497 {
331498 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>;
332507 /** Launch a URL target in the default browser. */
333508 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
334509 /**
......@@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service<
346521
347522const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
348523 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> {
350529 const platform = yield* HostProcessPlatform;
351 const env = yield* readCommandLookupEnv;
530 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
352531 yield* Effect.annotateCurrentSpan({
353532 "externalLauncher.editor": input.editor,
354533 "externalLauncher.cwd": input.cwd,
......@@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
376555 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
377556 }
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
379567 return {
380568 editor: editorDef.id,
381569 target: input.cwd,
382 command: fileManagerCommandForPlatform(platform),
383 args: [input.cwd],
570 command,
571 args:
572 command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined
573 ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)]
574 : [input.cwd],
384575 };
385576});
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
387678const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
388679 launch: ProcessLaunch,
389680 onError: (cause: unknown) => ExternalLauncherError,
......@@ -476,7 +767,9 @@ export const make = Effect.gen(function* () {
476767 if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) {
477768 return entry.value.editors;
478769 }
479 const editors = yield* provideCommandResolutionServices(resolveAvailableEditors());
770 const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe(
771 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
772 );
480773 yield* Ref.set(
481774 editorDiscoveryCache,
482775 Option.some({
......@@ -489,18 +782,18 @@ export const make = Effect.gen(function* () {
489782
490783 return ExternalLauncher.of({
491784 resolveAvailableEditors: () => cachedAvailableEditors,
785 resolveFileManagerRevealKind: () =>
786 provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe(
787 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
788 ),
492789 launchBrowser: (target) =>
493790 launchBrowser(target).pipe(
494791 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
495792 ),
496793 launchEditor: (input) =>
497794 provideCommandResolutionServices(
498 Effect.flatMap(resolveEditorLaunch(input), (launch) =>
499 launchEditorProcess(launch).pipe(
500 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
501 ),
502 ),
503 ),
795 Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess),
796 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
504797 });
505798});
506799
apps/server/src/server.test.ts+53-2
......@@ -102,7 +102,11 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function
102102import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
103103import * as ServerConfig from "./config.ts";
104104import { makeRoutesLayer } from "./server.ts";
105import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts";
105import {
106 isThreadDetailEvent,
107 resolveAvailableEditorsForConfig,
108 resolveFileManagerRevealKindForConfig,
109} from "./ws.ts";
106110import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
107111import * as GitManager from "./git/GitManager.ts";
108112import * as Keybindings from "./keybindings.ts";
......@@ -665,6 +669,7 @@ const buildAppUnderTest = (options?: {
665669 Layer.mergeAll(
666670 Layer.mock(ExternalLauncher.ExternalLauncher)({
667671 resolveAvailableEditors: () => Effect.succeed([]),
672 resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined),
668673 ...options?.layers?.externalLauncher,
669674 }),
670675 Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({
......@@ -4031,10 +4036,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
40314036 assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId);
40324037 assert.equal(response.auth.policy, "desktop-managed-local");
40334038 assert.equal(response.shellResumeCompletionMarker, true);
4039 assert.isUndefined(response.shellRevealInFileManager);
4040 assert.isUndefined(response.shellRevealInFileManagerKind);
40344041 assert.equal(response.threadResumeCompletionMarker, true);
40354042 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
40364043 );
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
40384071 it.effect("does not block server config when editor discovery never resolves", () =>
40394072 Effect.gen(function* () {
40404073 const discoveryInterrupted = yield* Deferred.make<void>();
......@@ -4052,6 +4085,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
40524085 }),
40534086 );
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
40554105 it.effect(
40564106 "rejects websocket rpc handshake when a session token is only provided via query string",
40574107 () =>
......@@ -4646,6 +4696,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
46464696
46474697 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
46484698 Effect.gen(function* () {
4699 const path = yield* Path.Path;
46494700 const providers = [
46504701 {
46514702 instanceId: ProviderInstanceId.make("codex"),
......@@ -4699,7 +4750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
46994750 assert.deepEqual(first.config.keybindings, []);
47004751 assert.deepEqual(first.config.issues, []);
47014752 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");
47034754 assert.equal(first.config.observability.localTracingEnabled, true);
47044755 assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces");
47054756 assert.equal(first.config.observability.otlpTracesEnabled, true);
apps/server/src/ws.ts+31-8
......@@ -19,6 +19,8 @@ import {
1919 CommandId,
2020 type DiscoveredLocalServerList,
2121 EventId,
22 type EditorId,
23 type FileManagerRevealKind,
2224 type OrchestrationClientOrigin,
2325 type OrchestrationCommand,
2426 type GitActionProgressEvent,
......@@ -136,16 +138,25 @@ import * as RelayClient from "@t3tools/shared/relayClient";
136138const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
137139
138140const 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>(
142 discovery: Effect.Effect<ReadonlyArray<A>, E, R>,
143const resolveDiscoveryForConfig = <A, E, R>(
144 discovery: Effect.Effect<A, E, R>,
145 onTimeout: () => A,
143146) =>
144147 discovery.pipe(
145 Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT),
146 Effect.map(Option.getOrElse(() => [])),
148 Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT),
149 Effect.map(Option.getOrElse(onTimeout)),
147150 );
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
149160function unexpectedCompatibilityError(error: never): never {
150161 throw new Error(`Unhandled compatibility error: ${String(error)}`);
151162}
......@@ -1109,6 +1120,14 @@ const makeWsRpcLayer = (
11091120 );
11101121 const environment = yield* serverEnvironment.getDescriptor;
11111122 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
11131132 return {
11141133 environment,
......@@ -1118,9 +1137,7 @@ const makeWsRpcLayer = (
11181137 keybindings: keybindingsConfig.keybindings,
11191138 issues: keybindingsConfig.issues,
11201139 providers,
1121 availableEditors: yield* resolveAvailableEditorsForConfig(
1122 externalLauncher.resolveAvailableEditors(),
1123 ),
1140 availableEditors,
11241141 // Same discovery-with-timeout treatment as editors: a slow probe
11251142 // must not stall server.getConfig, so it degrades to no targets.
11261143 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
......@@ -1138,6 +1155,12 @@ const makeWsRpcLayer = (
11381155 },
11391156 settings,
11401157 shellResumeCompletionMarker: true,
1158 ...(fileManagerRevealKind === undefined
1159 ? {}
1160 : {
1161 shellRevealInFileManager: true,
1162 shellRevealInFileManagerKind: fileManagerRevealKind,
1163 }),
11411164 threadResumeCompletionMarker: true,
11421165 threadSnapshotPagination: true,
11431166 };
apps/web/src/components/ChatMarkdown.test.tsx+133-3
......@@ -12,10 +12,15 @@ vi.mock("../state/session", async (importOriginal) => ({
1212}));
1313vi.mock("../state/entities", () => ({
1414 readThreadShell: () => null,
15 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
1615 useProjects: () => [],
1716}));
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}));
1924vi.mock("~/lib/openPullRequestLink", () => ({
2025 findProjectForChangeRequest: () => undefined,
2126 matchesLinkedPullRequestUrl: () => false,
......@@ -23,7 +28,124 @@ vi.mock("~/lib/openPullRequestLink", () => ({
2328 useOpenChangeRequestLink: () => vi.fn(),
2429}));
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
28150describe("orderedListGutterStyle", () => {
29151 it("leaves the default gutter alone for single-digit lists", () => {
......@@ -67,10 +189,13 @@ describe("orderedListGutterStyle", () => {
67189});
68190
69191describe("ChatMarkdown Windows file links", () => {
192 const environmentId = EnvironmentId.make("env-windows");
193
70194 it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => {
71195 const html = renderToStaticMarkup(
72196 <ChatMarkdown
73197 cwd="C:/Users/shawn/project"
198 environmentId={environmentId}
74199 text="[Open](C:/Users/shawn/project/src/main.ts)"
75200 lineBreaks={!parseRawHtml}
76201 parseRawHtml={parseRawHtml}
......@@ -85,6 +210,7 @@ describe("ChatMarkdown Windows file links", () => {
85210 const html = renderToStaticMarkup(
86211 <ChatMarkdown
87212 cwd="C:/Users/shawn/project"
213 environmentId={environmentId}
88214 text={String.raw`[Open](C:\Users\shawn\project\src\main.ts)`}
89215 lineBreaks={!parseRawHtml}
90216 parseRawHtml={parseRawHtml}
......@@ -101,6 +227,7 @@ describe("ChatMarkdown Windows file links", () => {
101227 const html = renderToStaticMarkup(
102228 <ChatMarkdown
103229 cwd="C:/Users/shawn/project"
230 environmentId={environmentId}
104231 text={String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`}
105232 lineBreaks={!parseRawHtml}
106233 parseRawHtml={parseRawHtml}
......@@ -119,6 +246,7 @@ describe("ChatMarkdown Windows file links", () => {
119246 const html = renderToStaticMarkup(
120247 <ChatMarkdown
121248 cwd="C:/Users/shawn/project"
249 environmentId={environmentId}
122250 text={`[Source](${path}) and \`${path}\``}
123251 lineBreaks={!parseRawHtml}
124252 parseRawHtml={parseRawHtml}
......@@ -134,6 +262,7 @@ describe("ChatMarkdown Windows file links", () => {
134262 const html = renderToStaticMarkup(
135263 <ChatMarkdown
136264 cwd="C:/Users/shawn/project"
265 environmentId={environmentId}
137266 text={"[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"}
138267 lineBreaks={!parseRawHtml}
139268 parseRawHtml={parseRawHtml}
......@@ -148,6 +277,7 @@ describe("ChatMarkdown Windows file links", () => {
148277 const html = renderToStaticMarkup(
149278 <ChatMarkdown
150279 cwd="C:/Users/shawn/project"
280 environmentId={environmentId}
151281 text="[unsafe](javascript:alert(1)) and [unknown](d:alert(1))"
152282 lineBreaks={!parseRawHtml}
153283 parseRawHtml={parseRawHtml}
apps/web/src/components/ChatMarkdown.tsx+282-54
......@@ -14,6 +14,7 @@ import {
1414 WrapTextIcon,
1515} from "lucide-react";
1616import type {
17 EnvironmentId,
1718 ScopedThreadRef,
1819 ServerProviderSkill,
1920 ThreadLinkedPullRequest,
......@@ -52,6 +53,10 @@ import { remarkGithubAlerts } from "../markdown-github-alerts";
5253import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
5354import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
5455import { PierreEntryIcon } from "./chat/PierreEntryIcon";
56import {
57 revealInFileExplorerLabelForKind,
58 revealInFileExplorerLabelForOs,
59} from "./preview/fileExplorerLabel";
5560import {
5661 resolveExternalWebLinkHost,
5762 showExternalLinkContextMenu,
......@@ -64,7 +69,12 @@ import { ScrollArea } from "./ui/scroll-area";
6469import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
6570import { stackedThreadToast, toastManager } from "./ui/toast";
6671import { recordVisitForThread } from "../browserHistoryStore";
67import { useOpenInPreferredEditor } from "../editorPreferences";
72import {
73 PreferredEditorEnvironmentRequiredError,
74 useOpenInPreferredEditor,
75 usePreferredEditor,
76} from "../editorPreferences";
77import { openInEditorMenuLabel } from "../editorLabels";
6878import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
6979import { fnv1a32 } from "../lib/diffRendering";
7080import { LRUCache } from "../lib/lruCache";
......@@ -91,9 +101,11 @@ import {
91101import { readLocalApi } from "../localApi";
92102import { useAssetUrlState } from "../assets/assetUrls";
93103import { cn } from "../lib/utils";
104import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";
94105import { useRightPanelStore } from "../rightPanelStore";
95import { readThreadShell, useActiveEnvironmentId, useProjects } from "../state/entities";
106import { readThreadShell, useProjects } from "../state/entities";
96107import { serverEnvironment } from "../state/server";
108import { shellEnvironment } from "../state/shell";
97109import { assetEnvironment } from "../state/assets";
98110import { usePreparedConnection } from "../state/session";
99111import { previewEnvironment } from "../state/preview";
......@@ -115,6 +127,7 @@ import {
115127} from "~/lib/openPullRequestLink";
116128import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
117129import { isPreviewSupportedInRuntime } from "../previewStateStore";
130import { resolvePathLinkTarget } from "../terminal-links";
118131import {
119132 isBrowserPreviewFile,
120133 openFileInPreview,
......@@ -126,6 +139,8 @@ interface ChatMarkdownProps {
126139 text: string;
127140 cwd: string | undefined;
128141 threadRef?: ScopedThreadRef | undefined;
142 /** Environment that owns non-thread markdown, such as a pull request panel. */
143 environmentId?: EnvironmentId | undefined;
129144 onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;
130145 isStreaming?: boolean;
131146 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
......@@ -136,6 +151,35 @@ interface ChatMarkdownProps {
136151 parseRawHtml?: boolean;
137152}
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
139183const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
140184
141185const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
......@@ -875,14 +919,19 @@ interface MarkdownFileLinkProps {
875919 copyMarkdown: string;
876920 theme: "light" | "dark";
877921 threadRef?: ScopedThreadRef | undefined;
878 onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;
922 onOpen?: ((targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
879923 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
924 openInEditorMenuLabel: string;
880925 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;
881930 className?: string | undefined;
882931}
883932
884const MARKDOWN_FILE_LINK_CLASS_NAME =
885 "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";
933const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link";
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
887936function pathParentSegments(path: string): string[] {
888937 const normalized = path.replaceAll("\\", "/");
......@@ -1229,10 +1278,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
12291278 threadRef,
12301279 onOpen,
12311280 onOpenInPanel,
1281 openInEditorMenuLabel,
12321282 onOpenInBrowser,
1283 onReveal,
1284 revealLabel,
12331285 className,
12341286}: MarkdownFileLinkProps) {
12351287 const handleOpenInEditor = useCallback(() => {
1288 if (!onOpen) {
1289 return;
1290 }
12361291 void (async () => {
12371292 try {
12381293 const result = await onOpen(targetPath);
......@@ -1313,6 +1368,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
13131368 })();
13141369 }, [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
13161409 const handleCopy = useCallback(
13171410 (value: string, title: string) => {
13181411 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
......@@ -1352,25 +1445,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
13521445 [targetPath],
13531446 );
13541447
1355 const handleContextMenu = useCallback(
1356 async (event: ReactMouseEvent<HTMLAnchorElement>) => {
1357 event.preventDefault();
1358 event.stopPropagation();
1359
1448 const showFileContextMenu = useCallback(
1449 async (position: { x: number; y: number }) => {
13601450 const api = readLocalApi();
13611451 if (!api) return;
13621452
13631453 try {
13641454 const clicked = await api.contextMenu.show(
13651455 [
1366 { id: "open", label: "Open in editor" },
1456 ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []),
13671457 ...(onOpenInBrowser
13681458 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
13691459 : []),
1460 ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []),
13701461 { id: "copy-relative", label: "Copy relative path" },
13711462 { id: "copy-full", label: "Copy full path" },
13721463 ] as const,
1373 { x: event.clientX, y: event.clientY },
1464 position,
13741465 );
13751466
13761467 if (clicked === "open") {
......@@ -1381,6 +1472,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
13811472 handleOpenInBrowser();
13821473 return;
13831474 }
1475 if (clicked === "reveal") {
1476 handleRevealInFileManager();
1477 return;
1478 }
13841479 if (clicked === "copy-relative") {
13851480 handleCopy(displayPath, "Relative path");
13861481 return;
......@@ -1395,34 +1490,100 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
13951490 );
13961491 }
13971492 },
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 ],
13991506 );
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
14011539 return (
14021540 <Tooltip>
14031541 <TooltipTrigger
14041542 render={
1405 <a
1406 href={href}
1407 className={cn(CHAT_FILE_TAG_CHIP_CLASS_NAME, MARKDOWN_FILE_LINK_CLASS_NAME, className)}
1408 data-markdown-copy={copyMarkdown}
1409 onClick={(event) => {
1410 event.preventDefault();
1411 event.stopPropagation();
1412 if (shouldOpenMarkdownFileLinkInEditor(event)) {
1413 handleOpenInEditor();
1414 return;
1415 }
1416 if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) {
1417 handleOpenInBrowser();
1418 return;
1419 }
1420 handleOpenInFilePreview();
1421 }}
1422 onContextMenu={handleContextMenu}
1423 >
1424 <FileTagChipContent path={iconPath} label={label} theme={theme} selectable />
1425 </a>
1543 hasPrimaryAction ? (
1544 <a
1545 href={href}
1546 className={cn(
1547 CHAT_FILE_TAG_CHIP_CLASS_NAME,
1548 MARKDOWN_FILE_LINK_CLASS_NAME,
1549 className,
1550 )}
1551 data-markdown-copy={copyMarkdown}
1552 onClick={(event) => {
1553 event.preventDefault();
1554 event.stopPropagation();
1555 if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) {
1556 handleOpenInEditor();
1557 return;
1558 }
1559 if (useBrowserPrimaryAction) {
1560 handleOpenInBrowser();
1561 return;
1562 }
1563 handleOpenInFilePreview();
1564 }}
1565 onContextMenu={handleContextMenu}
1566 >
1567 <FileTagChipContent path={iconPath} label={label} theme={theme} selectable />
1568 </a>
1569 ) : (
1570 <button
1571 type="button"
1572 aria-label={`File options for ${label}`}
1573 aria-haspopup="menu"
1574 className={cn(
1575 CHAT_FILE_TAG_CHIP_CLASS_NAME,
1576 MARKDOWN_FILE_LINK_CLASS_NAME,
1577 "select-text",
1578 className,
1579 )}
1580 data-markdown-copy={copyMarkdown}
1581 onClick={handleContextMenu}
1582 onContextMenu={handleContextMenu}
1583 >
1584 <FileTagChipContent path={iconPath} label={label} theme={theme} selectable />
1585 </button>
1586 )
14261587 }
14271588 />
14281589 <TooltipPopup
......@@ -1456,7 +1617,10 @@ function areMarkdownFileLinkPropsEqual(
14561617 previous.threadRef === next.threadRef &&
14571618 previous.onOpen === next.onOpen &&
14581619 previous.onOpenInPanel === next.onOpenInPanel &&
1620 previous.openInEditorMenuLabel === next.openInEditorMenuLabel &&
14591621 previous.onOpenInBrowser === next.onOpenInBrowser &&
1622 previous.onReveal === next.onReveal &&
1623 previous.revealLabel === next.revealLabel &&
14601624 previous.className === next.className
14611625 );
14621626}
......@@ -1465,6 +1629,7 @@ function ChatMarkdown({
14651629 text,
14661630 cwd,
14671631 threadRef,
1632 environmentId: explicitEnvironmentId,
14681633 onTaskListChange,
14691634 isStreaming = false,
14701635 skills = EMPTY_MARKDOWN_SKILLS,
......@@ -1485,16 +1650,49 @@ function ChatMarkdown({
14851650 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
14861651 reportFailure: false,
14871652 });
1488 const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null);
1489 const environmentId = useActiveEnvironmentId();
1653 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
1654 const remoteOpen = useRemoteOpenResolution(environmentId);
1655 const canUseShellActions = canUseMarkdownFileShellActions(
1656 environmentId,
1657 remoteOpen.state.mode,
1658 remoteOpen.isResolved,
1659 );
1660 const preparedConnection = usePreparedConnection(environmentId);
14901661 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
14911662 const threadServerConfig = useAtomValue(
14921663 serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId),
14931664 );
14941665 const projects = useProjects();
1495 const openInPreferredEditor = useOpenInPreferredEditor(
1496 environmentId,
1497 serverConfig?.availableEditors ?? [],
1666 const availableEditors = serverConfig?.availableEditors ?? [];
1667 const [preferredEditor] = usePreferredEditor(availableEditors);
1668 const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor);
1669 const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors);
1670 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
1671 reportFailure: false,
1672 });
1673 const revealInFileManagerLabel =
1674 environmentId !== null &&
1675 serverConfig?.shellRevealInFileManager === true &&
1676 serverConfig.availableEditors.includes("file-manager")
1677 ? serverConfig.shellRevealInFileManagerKind === undefined
1678 ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os)
1679 : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind)
1680 : undefined;
1681 const revealFileInFileManager = useCallback(
1682 (filePath: string) => {
1683 if (environmentId === null) {
1684 return Promise.resolve(
1685 AsyncResult.failure<void, PreferredEditorEnvironmentRequiredError>(
1686 Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })),
1687 ),
1688 );
1689 }
1690 return openInEditor({
1691 environmentId,
1692 input: { cwd: filePath, editor: "file-manager", reveal: true },
1693 });
1694 },
1695 [environmentId, openInEditor],
14981696 );
14991697 const diffThemeName = resolveDiffThemeName(resolvedTheme);
15001698 const markdownFileLinkMetaByHref = useMemo(() => {
......@@ -1636,6 +1834,26 @@ function ChatMarkdown({
16361834 },
16371835 [createAssetUrl, openPreview, preparedConnection, threadRef],
16381836 );
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 );
16391857 // A bare filename resolves to the workspace root, which is rarely where the
16401858 // file is, so ask the index before opening.
16411859 const openFileInPanel = useCallback(
......@@ -1651,24 +1869,23 @@ function ChatMarkdown({
16511869 return;
16521870 }
16531871 void (async () => {
1654 const result = await searchProjectEntries({
1655 environmentId: threadRef.environmentId,
1656 input: {
1657 cwd,
1658 query: workspaceRelativePath,
1659 limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
1660 kind: "file",
1661 },
1662 });
1663 const match =
1664 result._tag === "Success"
1665 ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries)
1666 : null;
1872 const match = await findWorkspaceBasenameMatch(workspaceRelativePath);
16671873 if (!isLatestLookup()) return;
16681874 openAt(match ?? workspaceRelativePath);
16691875 })();
16701876 },
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],
16721889 );
16731890 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component
16741891 * renderers that close over this message's metadata. useMemo keeps them stable until that
......@@ -1704,8 +1921,15 @@ function ChatMarkdown({
17041921 copyMarkdown={copyMarkdown}
17051922 theme={resolvedTheme}
17061923 threadRef={threadRef}
1707 onOpen={openInPreferredEditor}
1924 {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})}
17081925 onOpenInPanel={openFileInPanel}
1926 openInEditorMenuLabel={preferredEditorMenuLabel}
1927 onReveal={
1928 canUseShellActions && revealInFileManagerLabel !== undefined
1929 ? () => revealMarkdownFileInFileManager(fileLinkMeta)
1930 : undefined
1931 }
1932 revealLabel={revealInFileManagerLabel}
17091933 onOpenInBrowser={
17101934 threadRef &&
17111935 isPreviewSupportedInRuntime() &&
......@@ -1984,6 +2208,7 @@ function ChatMarkdown({
19842208 },
19852209 };
19862210 }, [
2211 canUseShellActions,
19872212 cwd,
19882213 diffThemeName,
19892214 fileLinkParentSuffixByPath,
......@@ -1996,8 +2221,11 @@ function ChatMarkdown({
19962221 openChangeRequestLink,
19972222 openExternalLinkInPreview,
19982223 openMarkdownFileInPreview,
2224 preferredEditorMenuLabel,
19992225 resolveThreadPullRequest,
20002226 resolvedTheme,
2227 revealMarkdownFileInFileManager,
2228 revealInFileManagerLabel,
20012229 skills,
20022230 text,
20032231 threadRef,
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+7-2
......@@ -25,10 +25,15 @@ vi.mock("../state/session", async (importOriginal) => ({
2525}));
2626vi.mock("../state/entities", () => ({
2727 readThreadShell: () => null,
28 useActiveEnvironmentId: () => EnvironmentId.make("env-windows"),
2928 useProjects: () => [],
3029}));
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}));
3237vi.mock("~/lib/openPullRequestLink", () => ({
3338 findProjectForChangeRequest: () => undefined,
3439 matchesLinkedPullRequestUrl: () => false,
apps/web/src/components/chat/OpenInPicker.tsx+6-28
......@@ -7,6 +7,7 @@ import {
77import { memo, useCallback, useEffect, useMemo } from "react";
88import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
99import { usePreferredEditor } from "../../editorPreferences";
10import { editorLabelForPlatform } from "../../editorLabels";
1011import {
1112 openRemoteEditorUrl,
1213 useRemoteCapableEditors,
......@@ -43,7 +44,7 @@ import {
4344 RustRoverIcon,
4445 WebStormIcon,
4546} from "../JetBrainsIcons";
46import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
47import { cn } from "~/lib/utils";
4748import { shellEnvironment } from "~/state/shell";
4849import { useAtomCommand } from "~/state/use-atom-command";
4950
......@@ -55,140 +56,117 @@ type OpenInOption = {
5556};
5657
5758const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {
58 const baseOptions: ReadonlyArray<OpenInOption> = [
59 const baseOptions: ReadonlyArray<Omit<OpenInOption, "label">> = [
5960 {
60 label: "Cursor",
6161 Icon: CursorIcon,
6262 value: "cursor",
6363 kind: "brand",
6464 },
6565 {
66 label: "Trae",
6766 Icon: TraeIcon,
6867 value: "trae",
6968 kind: "brand",
7069 },
7170 {
72 label: "Kiro",
7371 Icon: KiroIcon,
7472 value: "kiro",
7573 kind: "brand",
7674 },
7775 {
78 label: "VS Code",
7976 Icon: VisualStudioCode,
8077 value: "vscode",
8178 kind: "brand",
8279 },
8380 {
84 label: "VS Code Insiders",
8581 Icon: VisualStudioCodeInsiders,
8682 value: "vscode-insiders",
8783 kind: "brand",
8884 },
8985 {
90 label: "VSCodium",
9186 Icon: VSCodium,
9287 value: "vscodium",
9388 kind: "brand",
9489 },
9590 {
96 label: "Zed",
9791 Icon: Zed,
9892 value: "zed",
9993 kind: "brand",
10094 },
10195 {
102 label: "Antigravity",
10396 Icon: AntigravityIcon,
10497 value: "antigravity",
10598 kind: "brand",
10699 },
107100 {
108 label: "IntelliJ IDEA",
109101 Icon: IntelliJIdeaIcon,
110102 value: "idea",
111103 kind: "brand",
112104 },
113105 {
114 label: "Aqua",
115106 Icon: AquaIcon,
116107 value: "aqua",
117108 kind: "brand",
118109 },
119110 {
120 label: "CLion",
121111 Icon: CLionIcon,
122112 value: "clion",
123113 kind: "brand",
124114 },
125115 {
126 label: "DataGrip",
127116 Icon: DataGripIcon,
128117 value: "datagrip",
129118 kind: "brand",
130119 },
131120 {
132 label: "DataSpell",
133121 Icon: DataSpellIcon,
134122 value: "dataspell",
135123 kind: "brand",
136124 },
137125 {
138 label: "GoLand",
139126 Icon: GoLandIcon,
140127 value: "goland",
141128 kind: "brand",
142129 },
143130 {
144 label: "PhpStorm",
145131 Icon: PhpStormIcon,
146132 value: "phpstorm",
147133 kind: "brand",
148134 },
149135 {
150 label: "PyCharm",
151136 Icon: PyCharmIcon,
152137 value: "pycharm",
153138 kind: "brand",
154139 },
155140 {
156 label: "Rider",
157141 Icon: RiderIcon,
158142 value: "rider",
159143 kind: "brand",
160144 },
161145 {
162 label: "RubyMine",
163146 Icon: RubyMineIcon,
164147 value: "rubymine",
165148 kind: "brand",
166149 },
167150 {
168 label: "RustRover",
169151 Icon: RustRoverIcon,
170152 value: "rustrover",
171153 kind: "brand",
172154 },
173155 {
174 label: "WebStorm",
175156 Icon: WebStormIcon,
176157 value: "webstorm",
177158 kind: "brand",
178159 },
179160 {
180 label: isMacPlatform(platform)
181 ? "Finder"
182 : isWindowsPlatform(platform)
183 ? "Explorer"
184 : "Files",
185161 Icon: FolderClosedIcon,
186162 value: "file-manager",
187163 kind: "generic",
188164 },
189165 ];
190166 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) }));
192170};
193171
194172function getOpenInIconClass(kind: OpenInOption["kind"]) {
apps/web/src/components/preview/fileExplorerLabel.test.ts+26-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { revealInFileExplorerLabel } from "./fileExplorerLabel";
3import {
4 revealInFileExplorerLabel,
5 revealInFileExplorerLabelForKind,
6 revealInFileExplorerLabelForOs,
7} from "./fileExplorerLabel";
48
59describe("revealInFileExplorerLabel", () => {
610 it.each([
......@@ -11,3 +15,24 @@ describe("revealInFileExplorerLabel", () => {
1115 expect(revealInFileExplorerLabel(platform)).toBe(expected);
1216 });
1317});
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 @@
1import type { ExecutionEnvironmentPlatformOs, FileManagerRevealKind } from "@t3tools/contracts";
2
13export function revealInFileExplorerLabel(platform: string): string {
24 const normalized = platform.toLowerCase();
35 if (normalized.includes("mac")) return "Reveal in Finder";
46 if (normalized.includes("win")) return "Reveal in File Explorer";
57 return "Reveal in Files";
68}
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 @@
11import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";
2import type { EnvironmentId } from "@t3tools/contracts";
23
34import { cn } from "~/lib/utils";
45
......@@ -19,10 +20,12 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic";
1920export function PullRequestMarkdown({
2021 text,
2122 cwd,
23 environmentId,
2224 className,
2325}: {
2426 text: string;
2527 cwd: string;
28 environmentId: EnvironmentId;
2629 className?: string;
2730}) {
2831 const segments = splitPullRequestBody(text);
......@@ -30,7 +33,14 @@ export function PullRequestMarkdown({
3033 <div className={cn("space-y-3", className)}>
3134 {segments.map((segment) => {
3235 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 );
3444 }
3545 const isVideo = segment.media === "video";
3646 const Icon = isVideo ? PlayIcon : PaperclipIcon;
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx+4-1
......@@ -1,4 +1,5 @@
11import { useState } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
23
34import { cn } from "~/lib/utils";
45
......@@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown";
1718export function PullRequestMarkdownEditor({
1819 value,
1920 cwd,
21 environmentId,
2022 placeholder,
2123 label,
2224 saving,
......@@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({
2729}: {
2830 readonly value: string;
2931 readonly cwd: string;
32 readonly environmentId: EnvironmentId;
3033 readonly placeholder?: string | undefined;
3134 readonly label: string;
3235 readonly saving: boolean;
......@@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({
8184 {empty ? (
8285 <p className="text-xs text-muted-foreground">Nothing to preview.</p>
8386 ) : (
84 <PullRequestMarkdown text={draft} cwd={cwd} />
87 <PullRequestMarkdown text={draft} cwd={cwd} environmentId={environmentId} />
8588 )}
8689 </div>
8790 ) : (
apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx+2
......@@ -273,6 +273,7 @@ export function ReviewThreadCard({
273273 className="mt-1"
274274 value={comment.body}
275275 cwd={workspaceRoot}
276 environmentId={environmentId}
276277 label="Edit comment"
277278 saving={savingEdit}
278279 onSave={(body) => void saveEdit(comment.id, body)}
......@@ -284,6 +285,7 @@ export function ReviewThreadCard({
284285 className="min-w-0 flex-1 text-sm"
285286 text={comment.body}
286287 cwd={workspaceRoot}
288 environmentId={environmentId}
287289 />
288290 {canEditComment(comment) ? (
289291 <Button
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+11-1
......@@ -94,6 +94,7 @@ function reviewStateLabel(state: string): string {
9494/** What every remark in the conversation needs to be rewritten where it sits. */
9595interface CommentEditing {
9696 readonly cwd: string;
97 readonly environmentId: EnvironmentId;
9798 readonly canEdit: (comment: PullRequestComment) => boolean;
9899 readonly editingId: string | null;
99100 readonly saving: boolean;
......@@ -120,6 +121,7 @@ function CommentBody({
120121 className={className}
121122 value={comment.body}
122123 cwd={editing.cwd}
124 environmentId={editing.environmentId}
123125 label="Edit comment"
124126 saving={editing.saving}
125127 onSave={(body) => editing.onSave(comment, body)}
......@@ -129,7 +131,12 @@ function CommentBody({
129131 }
130132 return (
131133 <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 />
133140 {editing.canEdit(comment) ? (
134141 <Button
135142 size="icon-xs"
......@@ -489,6 +496,7 @@ export function PullRequestSummaryTab({
489496
490497 const commentEditing: CommentEditing = {
491498 cwd: detail.workspaceRoot,
499 environmentId,
492500 canEdit: (comment) => canEditPullRequestComment(detail, comment),
493501 editingId: editingCommentId,
494502 saving: commentSaving,
......@@ -649,6 +657,7 @@ export function PullRequestSummaryTab({
649657 allowEmpty
650658 value={detail.body}
651659 cwd={detail.workspaceRoot}
660 environmentId={environmentId}
652661 label="Pull request description"
653662 placeholder="Describe this pull request"
654663 saving={bodySaving}
......@@ -661,6 +670,7 @@ export function PullRequestSummaryTab({
661670 className="min-w-0 flex-1"
662671 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}
663672 cwd={detail.workspaceRoot}
673 environmentId={environmentId}
664674 />
665675 {canEditPullRequestChangeRequest(detail) ? (
666676 <Button
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+25-4
......@@ -59,11 +59,21 @@ interface ReactionSurface {
5959 readonly onRefresh: () => void;
6060}
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}) {
6373 return (
6474 <div className="mt-3">
6575 {markdown ? (
66 <PullRequestMarkdown text={body} cwd={cwd} />
76 <PullRequestMarkdown text={body} cwd={cwd} environmentId={environmentId} />
6777 ) : (
6878 <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p>
6979 )}
......@@ -235,6 +245,7 @@ function ConversationCard({
235245 <PullRequestMarkdownEditor
236246 value={editable.body}
237247 cwd={cwd}
248 environmentId={reactions.environmentId}
238249 label="Edit comment"
239250 saving={saving}
240251 onSave={(body) => void save(body)}
......@@ -243,7 +254,12 @@ function ConversationCard({
243254 </div>
244255 ) : event.body ? (
245256 <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 />
247263 </div>
248264 ) : null}
249265 {reactions.canReact || event.reactions.length > 0 ? (
......@@ -505,7 +521,12 @@ function ReviewVerdictEvent({
505521 {/* An approval usually carries no words. When it does they are the review, so they stay
506522 visible rather than being folded away with the ordinary conversation. */}
507523 {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 />
509530 ) : null}
510531 </div>
511532 <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 {
22712271 font-size: 0.75rem;
22722272}
22732273
2274.chat-markdown a.chat-markdown-file-link,
2275.chat-markdown a.chat-markdown-file-link:hover {
2274.chat-markdown .chat-markdown-file-link,
2275.chat-markdown .chat-markdown-file-link:hover {
22762276 color: var(--contrast-foreground);
22772277 text-decoration: none;
22782278}
22792279
2280.chat-markdown a.chat-markdown-file-link:focus-visible {
2280.chat-markdown .chat-markdown-file-link:focus-visible {
22812281 outline: none;
22822282 box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 70%, transparent);
22832283}
apps/web/src/remoteOpen.ts+24-8
......@@ -36,8 +36,17 @@ export type RemoteOpenState =
3636
3737export type RemoteOpenMode = RemoteOpenState["mode"];
3838
39export interface RemoteOpenResolution {
40 readonly state: RemoteOpenState;
41 readonly isResolved: boolean;
42}
43
3944const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" };
4045const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" };
46const UNRESOLVED_REMOTE_OPEN: RemoteOpenResolution = {
47 state: LOCAL_EXEC,
48 isResolved: false,
49};
4150
4251function parseHostname(url: string): string | null {
4352 try {
......@@ -87,25 +96,32 @@ export function resolveRemoteOpenState(input: {
8796 return REMOTE_UNAVAILABLE;
8897}
8998
90export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
99export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution {
91100 const { presentation } = useEnvironmentPresentation(environmentId);
92101
93102 return useMemo(() => {
94103 if (presentation === null) {
95 return LOCAL_EXEC;
104 return UNRESOLVED_REMOTE_OPEN;
96105 }
97106 const profile = Option.getOrNull(presentation.entry.profile);
98107 const sshAlias =
99108 profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null;
100 return resolveRemoteOpenState({
101 target: presentation.entry.target,
102 sshAlias,
103 remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,
104 isDesktopRenderer: window.desktopBridge !== undefined,
105 });
109 return {
110 state: resolveRemoteOpenState({
111 target: presentation.entry.target,
112 sshAlias,
113 remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,
114 isDesktopRenderer: window.desktopBridge !== undefined,
115 }),
116 isResolved: true,
117 };
106118 }, [presentation]);
107119}
108120
121export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
122 return useRemoteOpenResolution(environmentId).state;
123}
124
109125/**
110126 * Editors offered in remote-link mode. The desktop app probes the machine the
111127 * 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 = [
6969export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
7070export type EditorId = typeof EditorId.Type;
7171
72export const FileManagerRevealKind = Schema.Literals(["finder", "file-explorer", "files"]);
73export type FileManagerRevealKind = typeof FileManagerRevealKind.Type;
74
7275export const LaunchEditorInput = Schema.Struct({
7376 cwd: TrimmedNonEmptyString,
7477 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),
7582});
7683export type LaunchEditorInput = typeof LaunchEditorInput.Type;
7784
packages/contracts/src/server.ts+6-1
......@@ -17,7 +17,7 @@ import {
1717 KeybindingWhen,
1818 ResolvedKeybindingsConfig,
1919} from "./keybindings.ts";
20import { EditorId, RemoteOpenTarget } from "./editor.ts";
20import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts";
2121import { ModelCapabilities } from "./model.ts";
2222import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
2323import { ServerSettings } from "./settings.ts";
......@@ -438,6 +438,11 @@ export const ServerConfig = Schema.Struct({
438438 settings: ServerSettings,
439439 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
440440 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),
441446 /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */
442447 threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean),
443448 /**