From 3796c2f9818c6a5c8457360eba4287a4f9c80484 Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Wed, 25 Mar 2026 00:47:40 +0100 Subject: [PATCH] Add VS Code Insiders and VSCodium to Open In editor picker --- apps/server/src/open.test.ts | 41 +++- apps/server/src/open.ts | 8 +- apps/web/src/components/ChatView.browser.tsx | 179 ++++++++++++++++-- apps/web/src/components/chat/OpenInPicker.tsx | 10 + packages/contracts/src/editor.ts | 17 +- 5 files changed, 229 insertions(+), 26 deletions(-) diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 649e88462c..947a60ac2a 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -40,6 +40,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const vscodeInsidersLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLaunch, { + command: "code-insiders", + args: ["/tmp/workspace"], + }); + + const vscodiumLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLaunch, { + command: "codium", + args: ["/tmp/workspace"], + }); + const zedLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "zed" }, "darwin", @@ -80,6 +98,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLineAndColumn, { + command: "code-insiders", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + + const vscodiumLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLineAndColumn, { + command: "codium", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const zedLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, "darwin", @@ -220,13 +256,14 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fs.writeFileString(path.join(dir, "cursor.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); const editors = resolveAvailableEditors("win32", { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["cursor", "file-manager"]); + assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); }), ); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e7238c04b2..3fbfd1653d 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -39,10 +39,8 @@ interface CommandAvailabilityOptions { const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { - return ( - (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target) - ); +function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { + return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); } function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { @@ -213,7 +211,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } if (editorDef.command) { - return shouldUseGotoFlag(editorDef.id, input.cwd) + return shouldUseGotoFlag(editorDef, input.cwd) ? { command: editorDef.command, args: ["--goto", input.cwd] } : { command: editorDef.command, args: [input.cwd] }; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d4ff054672..a0301cd876 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -337,6 +337,25 @@ function withProjectScripts( }; } +function setDraftThreadWithoutWorktree(): void { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); +} + function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-target" as MessageId, @@ -989,30 +1008,162 @@ describe("ChatView timeline estimator parity (full app)", () => { ); it("opens the project cwd for draft threads without a worktree path", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { - projectId: PROJECT_ID, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode"], + }; + }, + }); + + try { + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscode", + }); }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode-insiders"], + }; }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + }); + + try { + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscode-insiders", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("filters the open picker menu and opens VSCodium from the menu", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode-insiders", "vscodium"], + }; }, }); + try { + const menuButton = await waitForElement( + () => document.querySelector('button[aria-label="Copy options"]'), + "Unable to find Open picker button.", + ); + (menuButton as HTMLButtonElement).click(); + + await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("VS Code Insiders"), + ) ?? null, + "Unable to find VS Code Insiders menu item.", + ); + + expect( + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) => + item.textContent?.includes("Zed"), + ), + ).toBe(false); + + const vscodiumItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("VSCodium"), + ) ?? null, + "Unable to find VSCodium menu item.", + ); + (vscodiumItem as HTMLElement).click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscodium", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to the first installed editor when the stored favorite is unavailable", async () => { + localStorage.setItem("t3code:last-editor", "vscodium"); + setDraftThreadWithoutWorktree(); + const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, - availableEditors: ["vscode"], + availableEditors: ["vscode-insiders"], }; }, }); @@ -1035,7 +1186,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", - editor: "vscode", + editor: "vscode-insiders", }); }, { timeout: 8_000, interval: 16 }, diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9f62f7121e..bbe527c28b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -22,6 +22,16 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray e.id));