From 9003267abae7a6360c0e9561b30c526cd0277e19 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 13 Oct 2026 10:00:57 -0500 Subject: [PATCH] fix(web): remember editor selections before opening folders - Select an editor in the picker without immediately opening the workspace - Prefer the macOS Zed app bundle over a conflicting `zed` on PATH --- .../src/process/externalLauncher.test.ts | 41 ++++++++++ .../src/components/chat/OpenInPicker.test.tsx | 80 ++++++++++++++++--- apps/web/src/components/chat/OpenInPicker.tsx | 8 +- packages/shared/src/editor.ts | 12 ++- 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index d16391a3ae70..0314ffad432e 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1043,6 +1043,47 @@ for (const { platform, installPath, editor, args } of [ ); } +for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) { + it.effect.skipIf(windowsHost)( + `opens ${cwd} with the macOS Zed bundle when another zed is on PATH`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-collision-" }); + const executable = path.join(home, "Applications/Zed.app/Contents/MacOS/cli"); + const binDir = path.join(home, "zfs/bin"); + yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); + yield* fs.makeDirectory(binDir, { recursive: true }); + for (const command of [executable, path.join(binDir, "zed")]) { + yield* fs.writeFileString(command, "#!/bin/sh\n"); + yield* fs.chmod(command, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + assert.include(yield* launcher.resolveAvailableEditors(), "zed"); + yield* launcher.launchEditor({ editor: "zed", cwd }); + }).pipe( + Effect.provide( + layerTest({ + platform: "darwin", + env: { HOME: home, PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.equal(spawned.command, executable); + assert.deepEqual(spawned.args, [cwd]); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} + // JetBrains Toolbox names its bundles after the app and the installed version // (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the // Applications directories the way the win32 branch does instead of probing diff --git a/apps/web/src/components/chat/OpenInPicker.test.tsx b/apps/web/src/components/chat/OpenInPicker.test.tsx index f7ad7c700cf0..a2d9e175b9cc 100644 --- a/apps/web/src/components/chat/OpenInPicker.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.test.tsx @@ -10,6 +10,7 @@ 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(), @@ -37,15 +38,25 @@ vi.mock("../../state/session", async () => { vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run })); vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) })); -vi.mock("../../editorPreferences", () => ({ - usePreferredEditor: (available: readonly EditorId[]) => [ - available[0] ?? null, - state.setPreferred, - ], -})); +vi.mock("../../editorPreferences", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePreferredEditor: (available: readonly EditorId[]) => { + const [preferred, setPreferred] = actual.usePreferredEditor(available); + return [ + preferred, + (editor: EditorId) => { + state.setPreferred(editor); + setPreferred(editor); + }, + ] as const; + }, + }; +}); vi.mock("../../remoteOpen", () => ({ useRemoteOpenState: () => state.remote, - useRemoteCapableEditors: () => editors, + useRemoteCapableEditors: () => state.clientEditors, useRemoteOpenHint: () => [false, state.markHintSeen], openRemoteEditorUrl: state.openUrl, })); @@ -104,6 +115,7 @@ let renderer: ReactTestRenderer | undefined; beforeEach(() => { 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(); @@ -112,11 +124,21 @@ beforeEach(() => { state.listeners.clear(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("navigator", { platform: "Linux" }); + const events = new EventTarget(); + const storage = new Map(); vi.stubGlobal("window", { - addEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => { - state.keydown = callback; + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + dispatchEvent: (event: Event) => events.dispatchEvent(event), + addEventListener: (type: string, callback: EventListener) => { + events.addEventListener(type, callback); + if (type === "keydown") state.keydown = callback; }, - removeEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => { + removeEventListener: (type: string, callback: EventListener) => { + events.removeEventListener(type, callback); if (state.keydown === callback) state.keydown = null; }, }); @@ -128,16 +150,17 @@ afterEach(async () => { vi.unstubAllGlobals(); }); -async function renderPicker() { +async function renderPicker(availableEditors: readonly EditorId[] = editors, panel = false) { await act(async () => { for (const listener of state.listeners) listener(); const node = ( ); if (renderer) renderer.update(node); @@ -153,6 +176,37 @@ function keyboardEvent() { return { preventDefault: vi.fn() } as unknown as KeyboardEvent; } +it.each(["local-exec", "remote-links"] as const)( + "selects the preferred editor without launching a %s workspace", + async (mode) => { + state.allowed.add(selected); + state.clientEditors = ["cursor", "vscode"]; + state.remote = + mode === "local-exec" ? { mode } : { mode, host: { kind: "ssh-alias", host: "test-host" } }; + await renderPicker(["cursor", "vscode"], true); + expect(renderer!.root.findByProps({ "aria-label": "Open in Cursor" })).toBeDefined(); + await act(async () => { + renderer!.root.findByProps({ children: "VS Code" }).parent!.props.onClick(); + }); + expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscode"'); + const openButton = renderer!.root.findByProps({ "aria-label": "Open in VS Code" }); + expect(state.run).not.toHaveBeenCalled(); + expect(state.openUrl).not.toHaveBeenCalled(); + + await act(async () => openButton.props.onClick()); + if (mode === "local-exec") { + expect(state.run).toHaveBeenCalledExactlyOnceWith({ + environmentId: selected, + input: { cwd: "/work/project", editor: "vscode" }, + }); + } else { + expect(state.openUrl).toHaveBeenCalledExactlyOnceWith( + "vscode://vscode-remote/ssh-remote+test-host/work/project", + ); + } + }, +); + describe("host editor access", () => { it("uses the selected grant for both the button and a retained shortcut", async () => { state.allowed.add(selected); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index a6f9bfbf7926..5cf20d954ae7 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -340,7 +340,13 @@ export const OpenInPicker = memo(function OpenInPicker({ density={presentation === "menu" ? "touch" : "default"} disabled={!openInCwd || !canOpenEditor} key={value} - onClick={() => openInEditor(value)} + onClick={() => { + if (presentation === "menu") { + void openInEditor(value); + } else { + setPreferredEditor(value); + } + }} >