Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions apps/web/src/components/chat/OpenInPicker.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ 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";

const state = vi.hoisted(() => ({
allowed: new Set<string>(),
remote: { mode: "local-exec" } as RemoteOpenState,
clientEditors: ["vscode"] as readonly EditorId[],
run: vi.fn(),
openUrl: vi.fn(),
setPreferred: vi.fn(),
Expand Down Expand Up @@ -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<typeof actual.usePreferredEditor>) => {
const [preferred, setPreferred] = actual.usePreferredEditor(...args);
const recordPreferred: typeof setPreferred = (editor) => {
state.setPreferred(editor);
setPreferred(editor);
Expand All @@ -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,
}));
Expand All @@ -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();
Expand Down Expand Up @@ -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();
});
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
if (!openInCwd) return false;
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/editorPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ export class PreferredEditorUnavailableError extends Schema.TaggedError<Preferre
}
}

export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) {
export function usePreferredEditor(
availableEditors: ReadonlyArray<EditorId>,
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;
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode"];

// Probed once per page: installed editors rarely change, and the probe spawns processes.
const remoteCapableEditorsAtom = Atom.make((get): ReadonlyArray<EditorId> => {
if (window.desktopBridge === undefined) return REMOTE_CAPABLE_EDITOR_IDS;
const probe = window.desktopBridge?.probeRemoteEditors;
probe?.().then(
(ids) => {
Expand Down
Loading