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