diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index d679f68107ad8..2d1b14f8e21fc 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -3,6 +3,7 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { Cause } from "effect"; import { AsyncResult } from "effect/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; import { act, fakeTimers, render as renderUi } from "../../../test/render"; import type { RemoteOpenState } from "../../remoteOpen"; @@ -10,7 +11,6 @@ import type { RemoteOpenState } from "../../remoteOpen"; const state = vi.hoisted(() => ({ allowed: new Set(), remote: { mode: "local-exec" } as RemoteOpenState, - clientEditors: ["vscode"] as readonly EditorId[], run: vi.fn(), openUrl: vi.fn(), setPreferred: vi.fn(), @@ -47,8 +47,8 @@ vi.mock(import("../../editorPreferences"), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - usePreferredEditor: (available: readonly EditorId[]) => { - const [preferred, setPreferred] = actual.usePreferredEditor(available); + usePreferredEditor: (...args: Parameters) => { + const [preferred, setPreferred] = actual.usePreferredEditor(...args); const recordPreferred: typeof setPreferred = (editor) => { state.setPreferred(editor); setPreferred(editor); @@ -60,7 +60,6 @@ vi.mock(import("../../editorPreferences"), async (importOriginal) => { vi.mock(import("../../remoteOpen"), async (importOriginal) => ({ ...(await importOriginal()), useRemoteOpenState: () => state.remote, - useRemoteCapableEditors: () => state.clientEditors, useRemoteOpenHint: () => [false, state.markHintSeen] as const, openRemoteEditorUrl: state.openUrl, })); @@ -75,7 +74,6 @@ const editors: readonly EditorId[] = ["vscode"]; async function setup() { state.allowed.clear(); state.remote = { mode: "local-exec" }; - state.clientEditors = editors; state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.openUrl.mockReset().mockResolvedValue(true); state.setPreferred.mockReset(); @@ -215,3 +213,36 @@ describe("open in editor announcement", () => { expect(await spoken()).toBe("Opened in VS Code"); }); }); + +it("offers remote browser editors without changing the default or forgetting a selection", async () => { + await using fixture = await setup(); + state.remote = { mode: "remote-links", host: { kind: "mdns", host: "test.local" } }; + await fixture.renderPicker(); + await fixture.click(fixture.primaryButton()); + expect(state.openUrl).toHaveBeenLastCalledWith( + "vscode://vscode-remote/ssh-remote+test.local/work/project", + ); + await fixture.click(fixture.byLabel("Choose editor")); + await expect.element(page.getByRole("menuitem", { name: /^Cursor/ })).toBeVisible(); + await expect.element(page.getByRole("menuitem", { name: "WebStorm", exact: true })).toBeVisible(); + await expect + .element(page.getByRole("menuitem", { name: "File Manager", exact: true })) + .not.toBeInTheDocument(); + await page.getByRole("menuitem", { name: "Zed", exact: true }).click(); + await fixture.click(fixture.primaryButton()); + expect(state.openUrl).toHaveBeenLastCalledWith("zed://ssh/test.local/work/project"); + await fixture.renderPicker(); + state.openUrl.mockClear(); + await fixture.pressOpenFavorite(); + expect(state.openUrl).toHaveBeenCalledExactlyOnceWith("zed://ssh/test.local/work/project"); + expect(state.run).not.toHaveBeenCalled(); +}); + +it("keeps local choices limited to installed host editors", async () => { + await using fixture = await setup(); + state.allowed.add(selected); + await fixture.renderPicker(); + await fixture.click(fixture.byLabel("Choose editor")); + await expect.element(page.getByRole("menuitem", { name: /^VS Code/ })).toBeVisible(); + await expect.element(page.getByRole("menuitem", { name: /^Cursor/ })).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 50e18dfe523b9..42f5c7b54b062 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -239,7 +239,10 @@ export const OpenInPicker = memo(function OpenInPicker({ const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; // Remote editors run on the viewing machine; only desktop can probe that PATH. const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; - const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); + const [preferredEditor, setPreferredEditor] = usePreferredEditor( + effectiveEditors, + remote.mode === "remote-links" && window.desktopBridge === undefined ? "vscode" : null, + ); const openInEditor = async (editorId: EditorId | null): Promise => { if (!openInCwd) return false; diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index e6aacd32b02de..ce325520aab71 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -44,13 +44,18 @@ export class PreferredEditorUnavailableError extends Schema.TaggedError) { +export function usePreferredEditor( + availableEditors: ReadonlyArray, + defaultEditor: EditorId | null = null, +) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); const effectiveEditor = lastEditor && availableEditors.includes(lastEditor) ? lastEditor - : (EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null); + : defaultEditor !== null && availableEditors.includes(defaultEditor) + ? defaultEditor + : (EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null); return [effectiveEditor, setLastEditor] as const; } diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 741f6f3c60cde..2b57c90c6244d 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -148,6 +148,7 @@ const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; // Probed once per page: installed editors rarely change, and the probe spawns processes. const remoteCapableEditorsAtom = Atom.make((get): ReadonlyArray => { + if (window.desktopBridge === undefined) return REMOTE_CAPABLE_EDITOR_IDS; const probe = window.desktopBridge?.probeRemoteEditors; probe?.().then( (ids) => {