authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-13 10:07:28-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 10:07:28-05:00
loga47decf230ac0562fc5f77ea88d92efc5eb61530
tree704872cfae4499c980f9708078c138d99389bc62
parent98a6246d07624432a6e6d0ab4fb87bede90a49a6
signature Signed by PGP key B5690EEEBB952194

fix(web): remember editor selections before opening folders (#19078)


4 files changed, 124 insertions(+), 17 deletions(-)

apps/server/src/process/externalLauncher.test.ts+41
...@@ -1043,6 +1043,47 @@ for (const { platform, installPath, editor, args } of [...@@ -1043,6 +1043,47 @@ for (const { platform, installPath, editor, args } of [
1043 );1043 );
1044}1044}
10451045
1046for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) {
1047 it.effect.skipIf(windowsHost)(
1048 `opens ${cwd} with the macOS Zed bundle when another zed is on PATH`,
1049 () =>
1050 Effect.gen(function* () {
1051 const fs = yield* FileSystem.FileSystem;
1052 const path = yield* Path.Path;
1053 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-collision-" });
1054 const executable = path.join(home, "Applications/Zed.app/Contents/MacOS/cli");
1055 const binDir = path.join(home, "zfs/bin");
1056 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
1057 yield* fs.makeDirectory(binDir, { recursive: true });
1058 for (const command of [executable, path.join(binDir, "zed")]) {
1059 yield* fs.writeFileString(command, "#!/bin/sh\n");
1060 yield* fs.chmod(command, 0o755);
1061 }
1062
1063 let spawned: ChildProcess.StandardCommand | undefined;
1064 yield* Effect.gen(function* () {
1065 const launcher = yield* ExternalLauncher.ExternalLauncher;
1066 assert.include(yield* launcher.resolveAvailableEditors(), "zed");
1067 yield* launcher.launchEditor({ editor: "zed", cwd });
1068 }).pipe(
1069 Effect.provide(
1070 layerTest({
1071 platform: "darwin",
1072 env: { HOME: home, PATH: binDir },
1073 onSpawn: (command) => {
1074 spawned = command;
1075 },
1076 }),
1077 ),
1078 );
1079 assert.ok(spawned);
1080 assert.equal(spawned.command, executable);
1081 assert.deepEqual(spawned.args, [cwd]);
1082 assert.equal(spawned.options.shell, false);
1083 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1084 );
1085}
1086
1046// JetBrains Toolbox names its bundles after the app and the installed version1087// JetBrains Toolbox names its bundles after the app and the installed version
1047// (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the1088// (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the
1048// Applications directories the way the win32 branch does instead of probing1089// Applications directories the way the win32 branch does instead of probing
apps/web/src/components/chat/OpenInPicker.test.tsx+67-13
...@@ -10,6 +10,7 @@ import type { RemoteOpenState } from "../../remoteOpen";...@@ -10,6 +10,7 @@ import type { RemoteOpenState } from "../../remoteOpen";
10const state = vi.hoisted(() => ({10const state = vi.hoisted(() => ({
11 allowed: new Set<string>(),11 allowed: new Set<string>(),
12 remote: { mode: "local-exec" } as RemoteOpenState,12 remote: { mode: "local-exec" } as RemoteOpenState,
13 clientEditors: ["vscode"] as readonly EditorId[],
13 run: vi.fn(),14 run: vi.fn(),
14 openUrl: vi.fn(),15 openUrl: vi.fn(),
15 setPreferred: vi.fn(),16 setPreferred: vi.fn(),
...@@ -37,15 +38,25 @@ vi.mock("../../state/session", async () => {...@@ -37,15 +38,25 @@ vi.mock("../../state/session", async () => {
37vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));38vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
38vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));39vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
39vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) }));40vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) }));
40vi.mock("../../editorPreferences", () => ({41vi.mock("../../editorPreferences", async (importOriginal) => {
41 usePreferredEditor: (available: readonly EditorId[]) => [42 const actual = await importOriginal<typeof import("../../editorPreferences")>();
42 available[0] ?? null,43 return {
43 state.setPreferred,44 ...actual,
44 ],45 usePreferredEditor: (available: readonly EditorId[]) => {
45}));46 const [preferred, setPreferred] = actual.usePreferredEditor(available);
47 return [
48 preferred,
49 (editor: EditorId) => {
50 state.setPreferred(editor);
51 setPreferred(editor);
52 },
53 ] as const;
54 },
55 };
56});
46vi.mock("../../remoteOpen", () => ({57vi.mock("../../remoteOpen", () => ({
47 useRemoteOpenState: () => state.remote,58 useRemoteOpenState: () => state.remote,
48 useRemoteCapableEditors: () => editors,59 useRemoteCapableEditors: () => state.clientEditors,
49 useRemoteOpenHint: () => [false, state.markHintSeen],60 useRemoteOpenHint: () => [false, state.markHintSeen],
50 openRemoteEditorUrl: state.openUrl,61 openRemoteEditorUrl: state.openUrl,
51}));62}));
...@@ -104,6 +115,7 @@ let renderer: ReactTestRenderer | undefined;...@@ -104,6 +115,7 @@ let renderer: ReactTestRenderer | undefined;
104beforeEach(() => {115beforeEach(() => {
105 state.allowed.clear();116 state.allowed.clear();
106 state.remote = { mode: "local-exec" };117 state.remote = { mode: "local-exec" };
118 state.clientEditors = editors;
107 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));119 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
108 state.openUrl.mockReset().mockResolvedValue(true);120 state.openUrl.mockReset().mockResolvedValue(true);
109 state.setPreferred.mockReset();121 state.setPreferred.mockReset();
...@@ -112,11 +124,21 @@ beforeEach(() => {...@@ -112,11 +124,21 @@ beforeEach(() => {
112 state.listeners.clear();124 state.listeners.clear();
113 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);125 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
114 vi.stubGlobal("navigator", { platform: "Linux" });126 vi.stubGlobal("navigator", { platform: "Linux" });
127 const events = new EventTarget();
128 const storage = new Map<string, string>();
115 vi.stubGlobal("window", {129 vi.stubGlobal("window", {
116 addEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {130 localStorage: {
117 state.keydown = callback;131 getItem: (key: string) => storage.get(key) ?? null,
132 setItem: (key: string, value: string) => storage.set(key, value),
133 removeItem: (key: string) => storage.delete(key),
134 },
135 dispatchEvent: (event: Event) => events.dispatchEvent(event),
136 addEventListener: (type: string, callback: EventListener) => {
137 events.addEventListener(type, callback);
138 if (type === "keydown") state.keydown = callback;
118 },139 },
119 removeEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {140 removeEventListener: (type: string, callback: EventListener) => {
141 events.removeEventListener(type, callback);
120 if (state.keydown === callback) state.keydown = null;142 if (state.keydown === callback) state.keydown = null;
121 },143 },
122 });144 });
...@@ -128,16 +150,17 @@ afterEach(async () => {...@@ -128,16 +150,17 @@ afterEach(async () => {
128 vi.unstubAllGlobals();150 vi.unstubAllGlobals();
129});151});
130152
131async function renderPicker() {153async function renderPicker(availableEditors: readonly EditorId[] = editors, panel = false) {
132 await act(async () => {154 await act(async () => {
133 for (const listener of state.listeners) listener();155 for (const listener of state.listeners) listener();
134 const node = (156 const node = (
135 <OpenInPicker157 <OpenInPicker
136 environmentId={selected}158 environmentId={selected}
137 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}159 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
138 availableEditors={editors}160 availableEditors={availableEditors}
139 openInCwd="/work/project"161 openInCwd="/work/project"
140 compact162 displayMode={panel ? "panel" : "toolbar"}
163 compact={!panel}
141 />164 />
142 );165 );
143 if (renderer) renderer.update(node);166 if (renderer) renderer.update(node);
...@@ -153,6 +176,37 @@ function keyboardEvent() {...@@ -153,6 +176,37 @@ function keyboardEvent() {
153 return { preventDefault: vi.fn() } as unknown as KeyboardEvent;176 return { preventDefault: vi.fn() } as unknown as KeyboardEvent;
154}177}
155178
179it.each(["local-exec", "remote-links"] as const)(
180 "selects the preferred editor without launching a %s workspace",
181 async (mode) => {
182 state.allowed.add(selected);
183 state.clientEditors = ["cursor", "vscode"];
184 state.remote =
185 mode === "local-exec" ? { mode } : { mode, host: { kind: "ssh-alias", host: "test-host" } };
186 await renderPicker(["cursor", "vscode"], true);
187 expect(renderer!.root.findByProps({ "aria-label": "Open in Cursor" })).toBeDefined();
188 await act(async () => {
189 renderer!.root.findByProps({ children: "VS Code" }).parent!.props.onClick();
190 });
191 expect(window.localStorage.getItem("t3code:last-editor")).toBe('"vscode"');
192 const openButton = renderer!.root.findByProps({ "aria-label": "Open in VS Code" });
193 expect(state.run).not.toHaveBeenCalled();
194 expect(state.openUrl).not.toHaveBeenCalled();
195
196 await act(async () => openButton.props.onClick());
197 if (mode === "local-exec") {
198 expect(state.run).toHaveBeenCalledExactlyOnceWith({
199 environmentId: selected,
200 input: { cwd: "/work/project", editor: "vscode" },
201 });
202 } else {
203 expect(state.openUrl).toHaveBeenCalledExactlyOnceWith(
204 "vscode://vscode-remote/ssh-remote+test-host/work/project",
205 );
206 }
207 },
208);
209
156describe("host editor access", () => {210describe("host editor access", () => {
157 it("uses the selected grant for both the button and a retained shortcut", async () => {211 it("uses the selected grant for both the button and a retained shortcut", async () => {
158 state.allowed.add(selected);212 state.allowed.add(selected);
apps/web/src/components/chat/OpenInPicker.tsx+7-1
...@@ -340,7 +340,13 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -340,7 +340,13 @@ export const OpenInPicker = memo(function OpenInPicker({
340 density={presentation === "menu" ? "touch" : "default"}340 density={presentation === "menu" ? "touch" : "default"}
341 disabled={!openInCwd || !canOpenEditor}341 disabled={!openInCwd || !canOpenEditor}
342 key={value}342 key={value}
343 onClick={() => openInEditor(value)}343 onClick={() => {
344 if (presentation === "menu") {
345 void openInEditor(value);
346 } else {
347 setPreferredEditor(value);
348 }
349 }}
344 >350 >
345 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />351 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
346 <MenuItemLabel>{label}</MenuItemLabel>352 <MenuItemLabel>{label}</MenuItemLabel>
packages/shared/src/editor.ts+9-3
...@@ -33,11 +33,16 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun...@@ -33,11 +33,16 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun
33) {33) {
34 if (editor.commands === null) return Option.none();34 if (editor.commands === null) return Option.none();
35 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];35 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
36 for (const command of editor.commands) {36 const platform = yield* HostProcess.Platform;
37 if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs });37 // OpenZFS also installs a `zed` executable. Prefer the macOS editor bundle
38 // so a PATH entry for the ZFS Event Daemon cannot take over editor opens.
39 const preferBundle = platform === "darwin" && editor.id === "zed";
40 if (!preferBundle) {
41 for (const command of editor.commands) {
42 if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs });
43 }
38 }44 }
3945
40 const platform = yield* HostProcess.Platform;
41 const path = yield* Path.Path;46 const path = yield* Path.Path;
42 const fs = yield* FileSystem.FileSystem;47 const fs = yield* FileSystem.FileSystem;
43 const home = env.HOME;48 const home = env.HOME;
...@@ -134,6 +139,7 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun...@@ -134,6 +139,7 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun
134 }139 }
135 }140 }
136141
142 if (preferBundle) candidates.push(...editor.commands);
137 for (const candidate of candidates) {143 for (const candidate of candidates) {
138 if (yield* isCommandAvailable(candidate, { env })) {144 if (yield* isCommandAvailable(candidate, { env })) {
139 return Option.some({145 return Option.some({