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 [
10431043 );
10441044}
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
10461087// JetBrains Toolbox names its bundles after the app and the installed version
10471088// (`IntelliJ IDEA 2026.1.4.app`), so the darwin branch has to list the
10481089// 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";
1010const state = vi.hoisted(() => ({
1111 allowed: new Set<string>(),
1212 remote: { mode: "local-exec" } as RemoteOpenState,
13 clientEditors: ["vscode"] as readonly EditorId[],
1314 run: vi.fn(),
1415 openUrl: vi.fn(),
1516 setPreferred: vi.fn(),
......@@ -37,15 +38,25 @@ vi.mock("../../state/session", async () => {
3738vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
3839vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
3940vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) }));
40vi.mock("../../editorPreferences", () => ({
41 usePreferredEditor: (available: readonly EditorId[]) => [
42 available[0] ?? null,
43 state.setPreferred,
44 ],
45}));
41vi.mock("../../editorPreferences", async (importOriginal) => {
42 const actual = await importOriginal<typeof import("../../editorPreferences")>();
43 return {
44 ...actual,
45 usePreferredEditor: (available: readonly EditorId[]) => {
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});
4657vi.mock("../../remoteOpen", () => ({
4758 useRemoteOpenState: () => state.remote,
48 useRemoteCapableEditors: () => editors,
59 useRemoteCapableEditors: () => state.clientEditors,
4960 useRemoteOpenHint: () => [false, state.markHintSeen],
5061 openRemoteEditorUrl: state.openUrl,
5162}));
......@@ -104,6 +115,7 @@ let renderer: ReactTestRenderer | undefined;
104115beforeEach(() => {
105116 state.allowed.clear();
106117 state.remote = { mode: "local-exec" };
118 state.clientEditors = editors;
107119 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
108120 state.openUrl.mockReset().mockResolvedValue(true);
109121 state.setPreferred.mockReset();
......@@ -112,11 +124,21 @@ beforeEach(() => {
112124 state.listeners.clear();
113125 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
114126 vi.stubGlobal("navigator", { platform: "Linux" });
127 const events = new EventTarget();
128 const storage = new Map<string, string>();
115129 vi.stubGlobal("window", {
116 addEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {
117 state.keydown = callback;
130 localStorage: {
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;
118139 },
119 removeEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {
140 removeEventListener: (type: string, callback: EventListener) => {
141 events.removeEventListener(type, callback);
120142 if (state.keydown === callback) state.keydown = null;
121143 },
122144 });
......@@ -128,16 +150,17 @@ afterEach(async () => {
128150 vi.unstubAllGlobals();
129151});
130152
131async function renderPicker() {
153async function renderPicker(availableEditors: readonly EditorId[] = editors, panel = false) {
132154 await act(async () => {
133155 for (const listener of state.listeners) listener();
134156 const node = (
135157 <OpenInPicker
136158 environmentId={selected}
137159 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
138 availableEditors={editors}
160 availableEditors={availableEditors}
139161 openInCwd="/work/project"
140 compact
162 displayMode={panel ? "panel" : "toolbar"}
163 compact={!panel}
141164 />
142165 );
143166 if (renderer) renderer.update(node);
......@@ -153,6 +176,37 @@ function keyboardEvent() {
153176 return { preventDefault: vi.fn() } as unknown as KeyboardEvent;
154177}
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
156210describe("host editor access", () => {
157211 it("uses the selected grant for both the button and a retained shortcut", async () => {
158212 state.allowed.add(selected);
apps/web/src/components/chat/OpenInPicker.tsx+7-1
......@@ -340,7 +340,13 @@ export const OpenInPicker = memo(function OpenInPicker({
340340 density={presentation === "menu" ? "touch" : "default"}
341341 disabled={!openInCwd || !canOpenEditor}
342342 key={value}
343 onClick={() => openInEditor(value)}
343 onClick={() => {
344 if (presentation === "menu") {
345 void openInEditor(value);
346 } else {
347 setPreferredEditor(value);
348 }
349 }}
344350 >
345351 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
346352 <MenuItemLabel>{label}</MenuItemLabel>
packages/shared/src/editor.ts+9-3
......@@ -33,11 +33,16 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun
3333) {
3434 if (editor.commands === null) return Option.none();
3535 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
36 for (const command of editor.commands) {
37 if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs });
36 const platform = yield* HostProcess.Platform;
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 }
3844 }
3945
40 const platform = yield* HostProcess.Platform;
4146 const path = yield* Path.Path;
4247 const fs = yield* FileSystem.FileSystem;
4348 const home = env.HOME;
......@@ -134,6 +139,7 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun
134139 }
135140 }
136141
142 if (preferBundle) candidates.push(...editor.commands);
137143 for (const candidate of candidates) {
138144 if (yield* isCommandAvailable(candidate, { env })) {
139145 return Option.some({