From c6b8bb82575c972ce858ddf3bdbf5386f2fb0f5e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 25 Aug 2026 02:47:06 -0700 Subject: [PATCH 01/15] feat(desktop): build macOS previews from a PR label (#8182) --- .github/workflows/desktop-macos-preview.yml | 165 ++++++++++++++++++++ scripts/build-desktop-artifact.test.ts | 39 +++++ scripts/build-desktop-artifact.ts | 26 +-- 3 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/desktop-macos-preview.yml diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml new file mode 100644 index 000000000000..6d1264aa7b7a --- /dev/null +++ b/.github/workflows/desktop-macos-preview.yml @@ -0,0 +1,165 @@ +name: Desktop macOS Preview + +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + name: Build macOS Apple Silicon preview + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'preview:mac') && + (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: false + + - name: Install desktop dependencies + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor + key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - id: version + name: Set preview version and public configuration + shell: bash + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + base_version="$(node -p "require('./apps/desktop/package.json').version")" + preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + node scripts/update-release-package-versions.ts "$preview_version" + cp .env.example .env + + echo "version=$preview_version" >> "$GITHUB_OUTPUT" + + - id: build + name: Build unsigned macOS DMG + shell: bash + env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + PREVIEW_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + vp run dist:desktop:artifact \ + --platform mac \ + --target dmg \ + --arch arm64 \ + --build-version "$PREVIEW_VERSION" \ + --verbose + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" + + - id: upload + name: Upload macOS DMG + uses: actions/upload-artifact@v7 + with: + path: release/*.dmg + if-no-files-found: error + archive: false + overwrite: true + retention-days: 7 + + - name: Comment download link + uses: actions/github-script@v8 + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + DMG_NAME: ${{ steps.build.outputs.dmg_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PREVIEW_VERSION: ${{ steps.version.outputs.version }} + with: + script: | + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + if (pullRequest.head.sha !== process.env.HEAD_SHA) { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.ARTIFACT_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "Unsigned build. Clear quarantine before opening:", + "```sh", + `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, + "```", + "", + "The download requires GitHub access and expires after 7 days.", + ].join("\n"); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + } diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 1b9e9b434488..a7bb1b9b7c4c 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -223,6 +223,45 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it.effect("omits update feeds for pull request preview builds", () => + Effect.gen(function* () { + const preview = yield* createBuildConfig( + "mac", + "dmg", + "0.0.33-pr.8182.1", + false, + false, + undefined, + undefined, + ); + const release = yield* createBuildConfig( + "mac", + "dmg", + "0.0.33", + false, + false, + undefined, + undefined, + ); + + assert.notProperty(preview, "publish"); + assert.deepStrictEqual(release.publish, [ + { + provider: "github", + owner: "pingdotgg", + repo: "t3code", + releaseType: "release", + }, + ]); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { GITHUB_REPOSITORY: "pingdotgg/t3code" } }), + ), + ), + ), + ); + it("omits bundled workspace packages from staged desktop dependencies", () => { assert.deepStrictEqual( resolveDesktopRuntimeDependencies( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index a9803d2bfa4a..dd8355e4c760 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2017,6 +2017,10 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } +function isDesktopPreviewVersion(version: string): boolean { + return /-pr\./.test(version); +} + export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { return resolveWebAssetBrandForChannel(resolveDesktopUpdateChannel(version)); } @@ -2093,16 +2097,18 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], }; const updateChannel = resolveDesktopUpdateChannel(version); - const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); - if (publishConfig) { - buildConfig.publish = [publishConfig]; - } else if (mockUpdates) { - buildConfig.publish = [ - { - provider: "generic", - url: resolveMockUpdateServerUrl(mockUpdateServerPort), - }, - ]; + if (!isDesktopPreviewVersion(version)) { + const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); + if (publishConfig) { + buildConfig.publish = [publishConfig]; + } else if (mockUpdates) { + buildConfig.publish = [ + { + provider: "generic", + url: resolveMockUpdateServerUrl(mockUpdateServerPort), + }, + ]; + } } if (platform === "mac") { From 5d7665396083d285132d67038813862a93337ca5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 25 Aug 2026 03:12:53 -0700 Subject: [PATCH 02/15] fix(web): thread jump hints no longer stick after a dictation paste (#8189) Co-authored-by: Claude Fable 5 --- apps/web/src/shortcutModifierState.test.ts | 25 ++++++++++++++++++++ apps/web/src/shortcutModifierState.ts | 27 ++++++++++++++++------ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts index cb62d45bcc0b..4ef4b3b6bf2d 100644 --- a/apps/web/src/shortcutModifierState.test.ts +++ b/apps/web/src/shortcutModifierState.test.ts @@ -110,4 +110,29 @@ describe("shortcutModifierState", () => { shiftKey: false, }); }); + + it("ignores poisoned modifier flags on non-modifier keys", () => { + // A dictation paste (synthetic ⌘V) can leave the browser reporting + // metaKey=true on later real key events. Enter to submit must not + // re-mark ⌘ as held. + const state = shortcutModifierStateAfterKeyboardEvent( + emptyState(), + keyboardEventLike("keydown", { key: "Enter", metaKey: true }), + ); + expect(state).toEqual(emptyState()); + }); + + it("clears a held modifier when a non-modifier key reports it released", () => { + const heldMeta: ShortcutModifierState = { + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }; + const state = shortcutModifierStateAfterKeyboardEvent( + heldMeta, + keyboardEventLike("keydown", { key: "a", metaKey: false }), + ); + expect(state).toEqual(emptyState()); + }); }); diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts index a56a1a129d07..3abeeaa3e8aa 100644 --- a/apps/web/src/shortcutModifierState.ts +++ b/apps/web/src/shortcutModifierState.ts @@ -33,7 +33,12 @@ export function useShortcutModifierState(): ShortcutModifierState { const onKeyboardEvent = (event: KeyboardEvent) => { setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); }; - const onWindowBlur = () => { + // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup + // never reaches the page, so the tracked state stays "⌘ held" forever and + // the thread jump hints stick on screen. A paste is never jump intent, so + // treat it like a blur and reset. A physically held modifier re-registers + // on the next real key event. + const onResetEvent = () => { setState((current) => areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) ? current @@ -43,11 +48,13 @@ export function useShortcutModifierState(): ShortcutModifierState { window.addEventListener("keydown", onKeyboardEvent, true); window.addEventListener("keyup", onKeyboardEvent, true); - window.addEventListener("blur", onWindowBlur); + window.addEventListener("paste", onResetEvent, true); + window.addEventListener("blur", onResetEvent); return () => { window.removeEventListener("keydown", onKeyboardEvent, true); window.removeEventListener("keyup", onKeyboardEvent, true); - window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("paste", onResetEvent, true); + window.removeEventListener("blur", onResetEvent); }; }, []); @@ -84,11 +91,17 @@ export function shortcutModifierStateAfterKeyboardEvent( [normalizedModifierKey]: event.type === "keydown", }; } else { + // Flags on non-modifier keys may only clear a bit, never set one. After a + // dictation tool's synthetic ⌘V (Wispr Flow), the browser can keep + // reporting metaKey=true on real key events (Enter to submit) until the + // user physically taps ⌘. Trusting that flag would mark ⌘ as held and + // stick the thread jump hints. Setting a bit requires a real modifier + // keydown, handled above. nextState = { - metaKey: event.metaKey, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - shiftKey: event.shiftKey, + metaKey: currentState.metaKey && event.metaKey, + ctrlKey: currentState.ctrlKey && event.ctrlKey, + altKey: currentState.altKey && event.altKey, + shiftKey: currentState.shiftKey && event.shiftKey, }; } From e67074f80933a27bd3cdc4e24f486358407690fb Mon Sep 17 00:00:00 2001 From: Mohtasham Murshid <154406804+MohtashamMurshid@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:55:42 +0800 Subject: [PATCH 03/15] fix(web): keep grouped project renames (#7831) --- .../ProjectSettingsPanel.logic.test.ts | 23 ++++++++++++++++++ .../settings/ProjectSettingsPanel.logic.ts | 7 ++++++ .../settings/ProjectSettingsPanel.tsx | 24 +++++++++++++++---- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts create mode 100644 apps/web/src/components/settings/ProjectSettingsPanel.logic.ts diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts new file mode 100644 index 000000000000..8a72b3510ceb --- /dev/null +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; + +describe("projectGroupTitleNeedsUpdate", () => { + it("updates divergent member titles even when the next title is the derived group label", () => { + expect( + projectGroupTitleNeedsUpdate(["local-title", "remote-title"], "Repository name", true), + ).toBe(true); + }); + + it("skips an untouched blur when the derived label differs from member titles", () => { + expect(projectGroupTitleNeedsUpdate(["repo-slug", "repo-slug"], "Repository Name", false)).toBe( + false, + ); + }); + + it("skips an update when every member already has the next title", () => { + expect(projectGroupTitleNeedsUpdate(["Shared name", "Shared name"], "Shared name", true)).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts new file mode 100644 index 000000000000..17ff824099fb --- /dev/null +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts @@ -0,0 +1,7 @@ +export function projectGroupTitleNeedsUpdate( + memberTitles: ReadonlyArray, + nextTitle: string, + wasEdited: boolean, +): boolean { + return wasEdited && memberTitles.some((title) => title !== nextTitle); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 6047b8fc48dc..b462eaca883b 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -113,6 +113,7 @@ import { canPickExternalProjectFavicon, ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; +import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -304,6 +305,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { reportFailure: false, }); + const projectNameEditedRef = useRef(false); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); @@ -392,17 +394,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); const renameGroup = useCallback( - async (nextTitle: string) => { + async (nextTitle: string, wasEdited: boolean) => { const title = nextTitle.trim(); if (!title) { toastManager.add({ type: "warning", title: "Project title cannot be empty" }); return; } - if (title === group.displayName) return; - if (group.memberProjects.every((member) => member.title === title)) return; + if ( + !projectGroupTitleNeedsUpdate( + group.memberProjects.map((member) => member.title), + title, + wasEdited, + ) + ) { + return; + } await updateAllMembers({ title }, "Failed to rename project"); }, - [group.displayName, group.memberProjects, updateAllMembers], + [group.memberProjects, updateAllMembers], ); // ----- default model ----- @@ -767,8 +776,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { className="w-full sm:w-64" aria-label="Project name" defaultValue={group.displayName} + onChange={() => { + projectNameEditedRef.current = true; + }} onBlur={(event) => { - void renameGroup(event.currentTarget.value); + const wasEdited = projectNameEditedRef.current; + projectNameEditedRef.current = false; + void renameGroup(event.currentTarget.value, wasEdited); }} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); From 082e6ea521861fff37b90fcd789b5eaa5ef5d6a6 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:05:16 -0400 Subject: [PATCH 04/15] feat(web): reveal chat file chips in the system file manager (#7140) Co-authored-by: Dara Adedeji Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../check-run-agents/ui-consistency.md | 7 + .../src/process/externalLauncher.test.ts | 648 +++++++++++++++++- apps/server/src/process/externalLauncher.ts | 331 ++++++++- apps/server/src/server.test.ts | 55 +- apps/server/src/ws.ts | 39 +- apps/web/src/components/ChatMarkdown.test.tsx | 136 +++- apps/web/src/components/ChatMarkdown.tsx | 336 +++++++-- .../ChatMarkdown.workspace-images.test.tsx | 9 +- apps/web/src/components/chat/OpenInPicker.tsx | 34 +- .../preview/fileExplorerLabel.test.ts | 27 +- .../components/preview/fileExplorerLabel.ts | 17 + .../pullRequest/PullRequestMarkdown.tsx | 12 +- .../pullRequest/PullRequestMarkdownEditor.tsx | 5 +- .../PullRequestReviewAnnotation.tsx | 2 + .../pullRequest/PullRequestSummaryTab.tsx | 12 +- .../pullRequest/PullRequestTimelineTab.tsx | 29 +- apps/web/src/editorLabels.test.ts | 29 + apps/web/src/editorLabels.ts | 19 + apps/web/src/index.css | 6 +- apps/web/src/remoteOpen.ts | 32 +- packages/contracts/src/editor.ts | 7 + packages/contracts/src/server.ts | 7 +- 22 files changed, 1658 insertions(+), 141 deletions(-) create mode 100644 apps/web/src/editorLabels.test.ts create mode 100644 apps/web/src/editorLabels.ts diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index c2c091b205cf..c2f2c57c1cf2 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -70,6 +70,13 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - 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. - 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. +## Environment routing in shared renderers + +- 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. +- 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. +- 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. +- 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. + ## Change discipline - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1ab6166e92a1..a72b42b60b75 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -function makeMockDetachedHandle(onUnref: () => void = () => undefined) { +interface MockSpawnResult { + readonly exitCode?: number; + readonly stdout?: string; + /** Never deliver an exit code, like a child wedged on a broken desktop session. */ + readonly stall?: boolean; +} + +function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: input.stall + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.sync(() => { - onUnref(); + input.onUnref?.(); return Effect.void; }), stdin: Sink.drain, - stdout: Stream.empty, + stdout: + input.stdout === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(input.stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -40,6 +57,7 @@ const testLayer = (input: { readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; + readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined; }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -50,7 +68,10 @@ const testLayer = (input: { throw new Error("Expected a standard command"); } input.onSpawn?.(command); - return makeMockDetachedHandle(input.onUnref); + return makeMockDetachedHandle({ + ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), + ...input.spawnResult?.(command), + }); }), ), ); @@ -132,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("reveals a file in Finder with open -R on macOS", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const openPath = path.join(binDir, "open"); + yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(openPath, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "open"); + assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a file in File Explorer through PowerShell on Windows", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows + // separators, which on the posix test filesystem is one file name. + const systemRoot = path.join(binDir, "system-root"); + const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); + yield* fileSystem.writeFileString(powerShellPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "C:\\workspace with spaces\\media\\author's clip.mp4", + reveal: true, + }); + return yield* launcher.resolveFileManagerRevealKind(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(kind, "file-explorer"); + assert.ok(spawned); + assert.equal(spawned.command, powerShellPath); + assert.deepEqual(spawned.args.slice(0, -1), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + // explorer.exe expects `/select,""` with only the path quoted; + // PowerShell 5.1's Start-Process passes the argument string verbatim. + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", + ); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Real-chain smoke check for the Explorer selection contract: runs the exact +// PowerShell source the reveal launch encodes, against a stub that records +// the raw argument tail it receives, and asserts a spaced path arrives as the +// single `/select,""` switch. Mock argv assertions cannot prove this — +// only Windows' own PowerShell -> CreateProcess quoting chain can, so the +// test runs only where that chain exists. +// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. +it.skipIf(process.platform !== "win32")( + "delivers the raw /select switch for spaced paths through real PowerShell", + { timeout: 60_000 }, + async () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); + try { + const recorderPath = NodePath.join(tempDir, "recorder.cmd"); + const outputPath = NodePath.join(tempDir, "argv.txt"); + NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); + + const target = "C:\\workspace with spaces\\media\\author's clip.mp4"; + const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(recorderPath, target); + const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + NodeChildProcess.execFileSync( + powerShellPath, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(source, "utf16le").toString("base64"), + ], + { timeout: 30_000 }, + ); + + // Start-Process returns before the recorder runs; wait for its output. + // The waits run outside the Effect runtime on purpose: the test + // exercises the real Windows process chain in real time. + // @effect-diagnostics-next-line globalTimers:off + const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); + // @effect-diagnostics-next-line globalDate:off + const deadline = Date.now() + 20_000; + // @effect-diagnostics-next-line globalDate:off + while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { + await sleep(100); + } + await sleep(200); + const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); + assert.equal(recorded, `/select,"${target}"`); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }, +); + +it.effect("does not advertise reveal on Windows when PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { + PATH: binDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + SYSTEMROOT: path.join(binDir, "missing-system-root"), + }, + }), + ), + ); + + // Plain "open in file manager" still works through explorer; only the + // reveal capability, which launches PowerShell, must stay hidden. + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a WSL file in Windows File Explorer through its UNC path", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { kind, editors }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + // The reveal routes through interop PowerShell so Explorer receives its + // raw `/select,""` switch even for spaced paths. + assert.equal(spawned.command, "powershell.exe"); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise reveal from WSL when interop PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const explorerPath = path.join(binDir, "explorer.exe"); + yield* fileSystem.writeFileString(explorerPath, ""); + yield* fileSystem.chmod(explorerPath, 0o755); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// When interop PowerShell is missing the capability advertises the Linux +// "files" kind (or nothing), so the reveal must open the Linux file manager +// the label promised even though plain open still prefers File Explorer. +it.effect("reveals through the Linux file manager when WSL lacks interop PowerShell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const revealKind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return revealKind; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false) +// while WSLg still provides a working Linux file manager; the host must keep +// the Linux open/reveal path instead of losing the editor entirely. +it.effect("falls back to the Linux file manager when WSL lacks the Explorer bridge", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { editors, kind }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.equal(result.kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "falls back to opening the containing directory for WSL paths Explorer cannot select", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: '/home/t3/work "quoted"/clip.mp4', + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + // Explorer's raw switch cannot express a double quote, so the launch + // opens the parent directory instead of misparsing a /select argument. + assert.ok(spawned); + assert.equal(spawned.command, "explorer.exe"); + assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals by opening the containing directory on Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager without a graphical session", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("advertises a Linux file manager when a directory handler is installed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let probe: ChildProcess.StandardCommand | undefined; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + probe = command; + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), true); + assert.ok(probe); + assert.equal(probe.command, "xdg-mime"); + assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// `xdg-open` with a display variable but no `inode/directory` handler exits +// nonzero after the launch has already detached: without this gate the server +// advertises a reveal that is a silent no-op. +it.effect("does not advertise a Linux file manager without a directory handler", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when the handler query fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// The handler probe carries its own timeout because the editor scan's outer +// timeout in server.getConfig degrades to an EMPTY editor list: a wedged +// xdg-mime must cost only the file manager, never the other editors. Runs on +// the live clock so the probe's real timeout fires. +it.live("a stalled handler probe drops only the file manager", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime", "code"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when xdg-mime is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("discovers editors through the service API", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ec928f26fc3..96e6470311f4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -15,6 +15,7 @@ import { ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, type EditorId, + type FileManagerRevealKind, type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -29,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({ SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), container: Config.string("container").pipe(Config.option), + DISPLAY: Config.string("DISPLAY").pipe(Config.option), + WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), }).pipe(Config.map(compactEnv)); const CommandLookupEnvConfig = Config.all({ @@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -function shouldUseWindowsBrowserFromWsl( +// File reveals from WSL resolve PowerShell through the interop PATH rather +// than the fixed /mnt/c mount: the automount root is configurable, and a +// PATH-resolved command keeps the advertised capability aligned with the +// availability check `launchEditor` performs before spawning. +const WSL_POWERSHELL_COMMAND = "powershell.exe"; + +function shouldUseWindowsHostFromWsl( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, ): boolean { @@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { +function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { + return [env.DISPLAY, env.WAYLAND_DISPLAY].some( + (value) => value !== undefined && value.trim().length > 0, + ); +} + +function fileManagerCommandForPlatform( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | undefined { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: - return "xdg-open"; + if (shouldUseWindowsHostFromWsl(platform, env)) { + return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; + } + return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; } } +// A graphical session variable plus an executable `xdg-open` does not prove +// that opening a directory does anything: without an `inode/directory` MIME +// handler, `xdg-open` exits nonzero after the launcher has already detached, +// so the client would see a silent no-op. Require the handler before +// advertising the file manager on Linux. +// +// The probe carries its own timeout well inside the scan timeout +// `server.getConfig` applies to editor discovery: that outer timeout degrades +// to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop +// session) must cost only the file manager, not every discovered editor. +const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; + +const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( + function* ( + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable("xdg-mime", { env }))) { + return false; + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .spawn( + ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.flatMap((handle) => + Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { + concurrency: "unbounded", + }), + ), + Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), + Effect.scoped, + Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), + Effect.orElseSucceed(() => false), + ); + }, +); + +const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( + function* ( + command: string, + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable(command, { env }))) { + return false; + } + return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); + }, +); + +// The file-manager command a launch can actually run, not just the platform +// preference. WSL hosts prefer the Windows Explorer bridge, but interop can +// exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a +// distro name while WSLg still provides a working Linux file manager, so they +// keep the `xdg-open` fallback instead of losing the editor entirely. +const resolveUsableFileManagerCommand = Effect.fn( + "externalLauncher.resolveUsableFileManagerCommand", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + string | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + const command = fileManagerCommandForPlatform(platform, env); + if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { + return command; + } + if ( + shouldUseWindowsHostFromWsl(platform, env) && + hasGraphicalLinuxSession(env) && + (yield* isUsableFileManagerCommand("xdg-open", env)) + ) { + return "xdg-open"; + } + return undefined; +}); + +// Reveal on Windows and WSL runs through PowerShell (see +// resolveFileManagerRevealLaunch), not the `explorer` command that gates the +// file-manager editor itself, so the capability must probe the executables the +// reveal actually spawns. Callers gate on file-manager availability first; +// the Linux "files" kind relies on that gate for the directory-handler probe, +// while the WSL fallback re-probes because its availability may have come +// from the Explorer bridge instead. +const fileManagerRevealKindForPlatform = Effect.fn( + "externalLauncher.fileManagerRevealKindForPlatform", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + FileManagerRevealKind | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") return "finder"; + if (platform === "win32") { + return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) + ? "file-explorer" + : undefined; + } + if (shouldUseWindowsHostFromWsl(platform, env)) { + if ( + env.WSL_DISTRO_NAME?.trim() && + (yield* isCommandAvailable("explorer.exe", { env })) && + (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) + ) { + return "file-explorer"; + } + return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) + ? "files" + : undefined; + } + return hasGraphicalLinuxSession(env) ? "files" : undefined; +}); + +function resolveWslFileManagerPath(target: string, distroName: string): string { + const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); + return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; +} + function buildBrowserLaunch( target: string, platform: NodeJS.Platform, @@ -251,7 +407,7 @@ function buildBrowserLaunch( return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); } - if (shouldUseWindowsBrowserFromWsl(platform, env)) { + if (shouldUseWindowsHostFromWsl(platform, env)) { return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); } @@ -265,13 +421,16 @@ function buildBrowserLaunch( const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +): Effect.fn.Return< + ReadonlyArray, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (yield* isCommandAvailable(command, { env })) { + if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { available.push(editor.id); } continue; @@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env); }); +const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( + function* () { + const platform = yield* HostProcessPlatform; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; + return yield* fileManagerRevealKindForPlatform(platform, env); + }, +); + // Editor discovery walks PATH for every known editor and runs for every // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the @@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; + /** + * Reveal kind for the host, or undefined when the executable a reveal + * actually spawns is unavailable. Only meaningful when + * `resolveAvailableEditors` includes "file-manager": on Linux that + * availability check also carries the directory-handler probe this + * capability relies on. + */ + readonly resolveFileManagerRevealKind: () => Effect.Effect; /** Launch a URL target in the default browser. */ readonly launchBrowser: (target: string) => Effect.Effect; /** @@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service< const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, -): Effect.fn.Return { +): Effect.fn.Return< + EditorLaunch, + ExternalLauncherError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const command = yield* resolveUsableFileManagerCommand(platform, env); + if (command === undefined) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); + } + + if (input.reveal === true) { + return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); + } + return { editor: editorDef.id, target: input.cwd, - command: fileManagerCommandForPlatform(platform), - args: [input.cwd], + command, + args: + command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined + ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] + : [input.cwd], }; }); +/** + * PowerShell source that launches File Explorer with its raw selection + * switch. Explorer's contract is the single argument `/select,""` with + * only the path quoted; Node's default spawn quoting wraps the whole argument + * when the path has spaces and Explorer misparses it, silently opening a + * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1 + * reaches the child's command line verbatim, preserving the raw switch. + * + * Exported so the Windows smoke test can drive the identical source through a + * real PowerShell against a recording stub instead of Explorer. + */ +export function buildFileExplorerRevealPowerShellSource( + explorerCommand: string, + target: string, +): string { + return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; +} + +function fileExplorerRevealLaunch( + target: string, + explorerTarget: string, + powershellCommand: string, +): EditorLaunch { + return { + editor: "file-manager", + target, + command: powershellCommand, + args: [ + ...POWERSHELL_ARGUMENTS_PREFIX, + encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), + ], + }; +} + +const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* ( + target: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + // The command resolveUsableFileManagerCommand picked; a WSL host that fell + // back to the Linux file manager must reveal through it as well. + command: string, +): Effect.fn.Return< + EditorLaunch, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") { + return { editor: "file-manager", target, command: "open", args: ["-R", target] }; + } + + if (platform === "win32") { + return fileExplorerRevealLaunch(target, target, resolvePowerShellPath(env)); + } + + if ( + command === "explorer.exe" && + shouldUseWindowsHostFromWsl(platform, env) && + env.WSL_DISTRO_NAME !== undefined + ) { + const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); + if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { + // Explorer's raw switch cannot express a double quote, and unlike + // Windows paths a WSL path may legally contain one: open the containing + // directory in File Explorer instead, matching the advertised + // "file-explorer" kind. + if (explorerTarget.includes('"')) { + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); + } + // Without interop PowerShell the capability advertised the Linux "files" + // kind when it advertised anything at all, so the reveal must open the + // Linux file manager the label promised, not File Explorer. + if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { + const path = yield* Path.Path; + return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] }; + } + // Nothing was advertised here; open the parent in File Explorer as the + // best remaining effort for a stale client. + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + + // Linux file managers have no portable "select this file" flag, so open + // the containing directory instead. + const path = yield* Path.Path; + return { editor: "file-manager", target, command, args: [path.dirname(target)] }; +}); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -476,7 +767,9 @@ export const make = Effect.gen(function* () { if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { return entry.value.editors; } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); yield* Ref.set( editorDiscoveryCache, Option.some({ @@ -489,18 +782,18 @@ export const make = Effect.gen(function* () { return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, + resolveFileManagerRevealKind: () => + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => provideCommandResolutionServices( - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ), - ), + Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess), + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c1674361d904..a9a2c3fa10d6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -102,7 +102,11 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; +import { + isThreadDetailEvent, + resolveAvailableEditorsForConfig, + resolveFileManagerRevealKindForConfig, +} from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -665,6 +669,7 @@ const buildAppUnderTest = (options?: { Layer.mergeAll( Layer.mock(ExternalLauncher.ExternalLauncher)({ resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), ...options?.layers?.externalLauncher, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -4031,10 +4036,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); assert.equal(response.shellResumeCompletionMarker, true); + assert.isUndefined(response.shellRevealInFileManager); + assert.isUndefined(response.shellRevealInFileManagerKind); assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("advertises the usable file manager and its reveal label", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.deepEqual(response.availableEditors, ["file-manager"]); + assert.equal(response.shellRevealInFileManager, true); + assert.equal(response.shellRevealInFileManagerKind, "file-explorer"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); @@ -4052,6 +4085,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); + it.effect("does not block server config when file manager reveal discovery never resolves", () => + Effect.gen(function* () { + const discoveryInterrupted = yield* Deferred.make(); + const responseFiber = yield* resolveFileManagerRevealKindForConfig( + Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), + ), + ).pipe(Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(5)); + + const revealKind = yield* Fiber.join(responseFiber); + yield* Deferred.await(discoveryInterrupted); + assert.isUndefined(revealKind); + }), + ); + it.effect( "rejects websocket rpc handshake when a session token is only provided via query string", () => @@ -4646,6 +4696,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const path = yield* Path.Path; const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -4699,7 +4750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); + assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a9f8f5f0bb50..226c82cdb1ac 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -19,6 +19,8 @@ import { CommandId, type DiscoveredLocalServerList, EventId, + type EditorId, + type FileManagerRevealKind, type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, @@ -136,16 +138,25 @@ import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5); +const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); -export const resolveAvailableEditorsForConfig = ( - discovery: Effect.Effect, E, R>, +const resolveDiscoveryForConfig = ( + discovery: Effect.Effect, + onTimeout: () => A, ) => discovery.pipe( - Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT), - Effect.map(Option.getOrElse(() => [])), + Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT), + Effect.map(Option.getOrElse(onTimeout)), ); +export const resolveAvailableEditorsForConfig = ( + discovery: Effect.Effect, E, R>, +) => resolveDiscoveryForConfig(discovery, () => []); + +export const resolveFileManagerRevealKindForConfig = ( + discovery: Effect.Effect, +) => resolveDiscoveryForConfig(discovery, () => undefined); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1109,6 +1120,14 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; return { environment, @@ -1118,9 +1137,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ), + availableEditors, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. remoteOpenTargets: yield* resolveAvailableEditorsForConfig( @@ -1138,6 +1155,12 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, }; diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 1edf37e9b84a..2ca9ad4ae311 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -12,10 +12,15 @@ vi.mock("../state/session", async (importOriginal) => ({ })); vi.mock("../state/entities", () => ({ readThreadShell: () => null, - useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), useProjects: () => [], })); -vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); vi.mock("~/lib/openPullRequestLink", () => ({ findProjectForChangeRequest: () => undefined, matchesLinkedPullRequestUrl: () => false, @@ -23,7 +28,124 @@ vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn(), })); -import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown"; +import ChatMarkdown, { + canUseMarkdownFileShellActions, + hasMarkdownFilePrimaryAction, + orderedListGutterStyle, + shouldUseMarkdownFileBrowserPrimaryAction, +} from "./ChatMarkdown"; + +describe("canUseMarkdownFileShellActions", () => { + const environmentId = EnvironmentId.make("environment-1"); + + it("allows editor and file manager actions for local environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); + }); + + it("hides shell actions until the environment mode is resolved", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); + }); + + it("hides editor and file manager actions for remote environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); + expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); + }); + + it("hides shell actions when no environment owns the markdown", () => { + expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); + }); +}); + +describe("hasMarkdownFilePrimaryAction", () => { + it("keeps the chip interactive when an editor, browser, or panel can open it", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: true, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: true, + }), + ).toBe(true); + }); + + it("removes the link affordance when no primary action can open the file", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(false); + }); +}); + +describe("ChatMarkdown file option chips", () => { + it("keeps the fallback button text selectable", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(" { + it("uses the browser when it is the only available primary action", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + }); + + it("preserves the normal editor and panel defaults for HTML files", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(false); + }); + + it("continues to open PDF files in the browser by default", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.pdf", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(true); + }); +}); describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -67,10 +189,13 @@ describe("orderedListGutterStyle", () => { }); describe("ChatMarkdown Windows file links", () => { + const environmentId = EnvironmentId.make("env-windows"); + it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; @@ -136,6 +151,35 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; } +export function canUseMarkdownFileShellActions( + environmentId: EnvironmentId | null, + remoteOpenMode: RemoteOpenMode, + isRemoteOpenResolved: boolean, +): boolean { + return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; +} + +export function hasMarkdownFilePrimaryAction(input: { + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; +} + +export function shouldUseMarkdownFileBrowserPrimaryAction(input: { + iconPath: string; + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return ( + input.canOpenInBrowser && + (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || + (!input.canOpenInEditor && !input.canOpenInPanel)) + ); +} + const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -875,14 +919,19 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; - onOpen: (targetPath: string) => Promise>; + onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onReveal?: (() => Promise>) | undefined; + /** Platform-specific menu label ("Reveal in Finder", ...); required for the + reveal item to show. */ + revealLabel?: string | undefined; className?: string | undefined; } -const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; +const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; +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`; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); @@ -1229,10 +1278,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + openInEditorMenuLabel, onOpenInBrowser, + onReveal, + revealLabel, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { + if (!onOpen) { + return; + } void (async () => { try { const result = await onOpen(targetPath); @@ -1313,6 +1368,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpenInBrowser, targetPath]); + const handleRevealInFileManager = useCallback(() => { + if (!onReveal) { + return; + } + void (async () => { + try { + const result = await onReveal(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [onReveal, targetPath]); + const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -1352,25 +1445,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [targetPath], ); - const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - + const showFileContextMenu = useCallback( + async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; try { const clicked = await api.contextMenu.show( [ - { id: "open", label: "Open in editor" }, + ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), + ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, - { x: event.clientX, y: event.clientY }, + position, ); if (clicked === "open") { @@ -1381,6 +1472,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "reveal") { + handleRevealInFileManager(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1395,34 +1490,100 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + onOpen, + onReveal, + openInEditorMenuLabel, + revealLabel, + targetPath, + ], ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const position = + event.clientX === 0 && event.clientY === 0 + ? (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })() + : { x: event.clientX, y: event.clientY }; + void showFileContextMenu(position); + }, + [showFileContextMenu], + ); + + const canOpenInEditor = onOpen !== undefined; + const canOpenInBrowser = onOpenInBrowser !== undefined; + const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const hasPrimaryAction = hasMarkdownFilePrimaryAction({ + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath, + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + return ( { - event.preventDefault(); - event.stopPropagation(); - if (shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - + hasPrimaryAction ? ( + { + event.preventDefault(); + event.stopPropagation(); + if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (useBrowserPrimaryAction) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + + ) } /> { + if (environmentId === null) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), + ), + ); + } + return openInEditor({ + environmentId, + input: { cwd: filePath, editor: "file-manager", reveal: true }, + }); + }, + [environmentId, openInEditor], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { @@ -1636,6 +1834,26 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + const findWorkspaceBasenameMatch = useCallback( + async (workspaceRelativePath: string) => { + if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + return null; + } + const result = await searchProjectEntries({ + environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + return result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + }, + [cwd, environmentId, searchProjectEntries], + ); // A bare filename resolves to the workspace root, which is rarely where the // file is, so ask the index before opening. const openFileInPanel = useCallback( @@ -1651,24 +1869,23 @@ function ChatMarkdown({ return; } void (async () => { - const result = await searchProjectEntries({ - environmentId: threadRef.environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - const match = - result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; + const match = await findWorkspaceBasenameMatch(workspaceRelativePath); if (!isLatestLookup()) return; openAt(match ?? workspaceRelativePath); })(); }, - [cwd, searchProjectEntries, threadRef], + [cwd, findWorkspaceBasenameMatch, threadRef], + ); + const revealMarkdownFileInFileManager = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; + const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; + return revealFileInFileManager(filePath); + }, + [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that @@ -1704,8 +1921,15 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} - onOpen={openInPreferredEditor} + {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} onOpenInPanel={openFileInPanel} + openInEditorMenuLabel={preferredEditorMenuLabel} + onReveal={ + canUseShellActions && revealInFileManagerLabel !== undefined + ? () => revealMarkdownFileInFileManager(fileLinkMeta) + : undefined + } + revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1984,6 +2208,7 @@ function ChatMarkdown({ }, }; }, [ + canUseShellActions, cwd, diffThemeName, fileLinkParentSuffixByPath, @@ -1996,8 +2221,11 @@ function ChatMarkdown({ openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, + preferredEditorMenuLabel, resolveThreadPullRequest, resolvedTheme, + revealMarkdownFileInFileManager, + revealInFileManagerLabel, skills, text, threadRef, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index e7b042a62c95..37a0f27a0ba3 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -25,10 +25,15 @@ vi.mock("../state/session", async (importOriginal) => ({ })); vi.mock("../state/entities", () => ({ readThreadShell: () => null, - useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), useProjects: () => [], })); -vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); vi.mock("~/lib/openPullRequestLink", () => ({ findProjectForChangeRequest: () => undefined, matchesLinkedPullRequestUrl: () => false, diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index afe35e185203..b9bf831c14d9 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -7,6 +7,7 @@ import { import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; +import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, useRemoteCapableEditors, @@ -43,7 +44,7 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; -import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -55,140 +56,117 @@ type OpenInOption = { }; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { - const baseOptions: ReadonlyArray = [ + const baseOptions: ReadonlyArray> = [ { - label: "Cursor", Icon: CursorIcon, value: "cursor", kind: "brand", }, { - label: "Trae", Icon: TraeIcon, value: "trae", kind: "brand", }, { - label: "Kiro", Icon: KiroIcon, value: "kiro", kind: "brand", }, { - label: "VS Code", Icon: VisualStudioCode, value: "vscode", kind: "brand", }, { - label: "VS Code Insiders", Icon: VisualStudioCodeInsiders, value: "vscode-insiders", kind: "brand", }, { - label: "VSCodium", Icon: VSCodium, value: "vscodium", kind: "brand", }, { - label: "Zed", Icon: Zed, value: "zed", kind: "brand", }, { - label: "Antigravity", Icon: AntigravityIcon, value: "antigravity", kind: "brand", }, { - label: "IntelliJ IDEA", Icon: IntelliJIdeaIcon, value: "idea", kind: "brand", }, { - label: "Aqua", Icon: AquaIcon, value: "aqua", kind: "brand", }, { - label: "CLion", Icon: CLionIcon, value: "clion", kind: "brand", }, { - label: "DataGrip", Icon: DataGripIcon, value: "datagrip", kind: "brand", }, { - label: "DataSpell", Icon: DataSpellIcon, value: "dataspell", kind: "brand", }, { - label: "GoLand", Icon: GoLandIcon, value: "goland", kind: "brand", }, { - label: "PhpStorm", Icon: PhpStormIcon, value: "phpstorm", kind: "brand", }, { - label: "PyCharm", Icon: PyCharmIcon, value: "pycharm", kind: "brand", }, { - label: "Rider", Icon: RiderIcon, value: "rider", kind: "brand", }, { - label: "RubyMine", Icon: RubyMineIcon, value: "rubymine", kind: "brand", }, { - label: "RustRover", Icon: RustRoverIcon, value: "rustrover", kind: "brand", }, { - label: "WebStorm", Icon: WebStormIcon, value: "webstorm", kind: "brand", }, { - label: isMacPlatform(platform) - ? "Finder" - : isWindowsPlatform(platform) - ? "Explorer" - : "Files", Icon: FolderClosedIcon, value: "file-manager", kind: "generic", }, ]; const availableEditorSet = new Set(availableEditors); - return baseOptions.filter((option) => availableEditorSet.has(option.value)); + return baseOptions + .filter((option) => availableEditorSet.has(option.value)) + .map((option) => ({ ...option, label: editorLabelForPlatform(option.value, platform) })); }; function getOpenInIconClass(kind: OpenInOption["kind"]) { diff --git a/apps/web/src/components/preview/fileExplorerLabel.test.ts b/apps/web/src/components/preview/fileExplorerLabel.test.ts index 0b39a8d9ef99..3848c503c237 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.test.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { revealInFileExplorerLabel } from "./fileExplorerLabel"; +import { + revealInFileExplorerLabel, + revealInFileExplorerLabelForKind, + revealInFileExplorerLabelForOs, +} from "./fileExplorerLabel"; describe("revealInFileExplorerLabel", () => { it.each([ @@ -11,3 +15,24 @@ describe("revealInFileExplorerLabel", () => { expect(revealInFileExplorerLabel(platform)).toBe(expected); }); }); + +describe("revealInFileExplorerLabelForOs", () => { + it.each([ + ["darwin", "Reveal in Finder"], + ["windows", "Reveal in File Explorer"], + ["linux", "Reveal in Files"], + ["unknown", "Reveal in Files"], + ] as const)("maps %s to %s", (os, expected) => { + expect(revealInFileExplorerLabelForOs(os)).toBe(expected); + }); +}); + +describe("revealInFileExplorerLabelForKind", () => { + it.each([ + ["finder", "Reveal in Finder"], + ["file-explorer", "Reveal in File Explorer"], + ["files", "Reveal in Files"], + ] as const)("maps %s to %s", (kind, expected) => { + expect(revealInFileExplorerLabelForKind(kind)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/preview/fileExplorerLabel.ts b/apps/web/src/components/preview/fileExplorerLabel.ts index fd0785dfadf7..5229782721d0 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.ts @@ -1,6 +1,23 @@ +import type { ExecutionEnvironmentPlatformOs, FileManagerRevealKind } from "@t3tools/contracts"; + export function revealInFileExplorerLabel(platform: string): string { const normalized = platform.toLowerCase(); if (normalized.includes("mac")) return "Reveal in Finder"; if (normalized.includes("win")) return "Reveal in File Explorer"; return "Reveal in Files"; } + +/** Same wording keyed by an environment's reported OS rather than a + navigator platform string, for actions that reveal on the server machine. */ +export function revealInFileExplorerLabelForOs(os: ExecutionEnvironmentPlatformOs): string { + if (os === "darwin") return "Reveal in Finder"; + if (os === "windows") return "Reveal in File Explorer"; + return "Reveal in Files"; +} + +/** Server-selected wording, including Windows File Explorer reached from WSL. */ +export function revealInFileExplorerLabelForKind(kind: FileManagerRevealKind): string { + if (kind === "finder") return "Reveal in Finder"; + if (kind === "file-explorer") return "Reveal in File Explorer"; + return "Reveal in Files"; +} diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 46aa44dc1289..3e8cdcd6b5d0 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,4 +1,5 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; @@ -19,10 +20,12 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; export function PullRequestMarkdown({ text, cwd, + environmentId, className, }: { text: string; cwd: string; + environmentId: EnvironmentId; className?: string; }) { const segments = splitPullRequestBody(text); @@ -30,7 +33,14 @@ export function PullRequestMarkdown({
{segments.map((segment) => { if (segment.kind === "markdown") { - return ; + return ( + + ); } const isVideo = segment.media === "video"; const Icon = isVideo ? PlayIcon : PaperclipIcon; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index f0145c059c0d..d5d2ee0a4757 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; @@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; export function PullRequestMarkdownEditor({ value, cwd, + environmentId, placeholder, label, saving, @@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({ }: { readonly value: string; readonly cwd: string; + readonly environmentId: EnvironmentId; readonly placeholder?: string | undefined; readonly label: string; readonly saving: boolean; @@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({ {empty ? (

Nothing to preview.

) : ( - + )}
) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index c2e95ee41e12..90a1926d1fad 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -273,6 +273,7 @@ export function ReviewThreadCard({ className="mt-1" value={comment.body} cwd={workspaceRoot} + environmentId={environmentId} label="Edit comment" saving={savingEdit} onSave={(body) => void saveEdit(comment.id, body)} @@ -284,6 +285,7 @@ export function ReviewThreadCard({ className="min-w-0 flex-1 text-sm" text={comment.body} cwd={workspaceRoot} + environmentId={environmentId} /> {canEditComment(comment) ? (