authorgravatar for najjarnassim1995@gmail.comNassim Najjar <najjarnassim1995@gmail.com> 2026-03-27 05:46:43+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-27 04:46:43+00:00
log648f0676bde993ae64e57d2e2ccd2e797803de32
treee9299cf7ec4518b16bf5cc1c25d4c11b7b123b08
parentadd5f34619b65c3283317d8c77e1feef6fc63267
signature Signed by PGP key B5690EEEBB952194

Add VS Code Insiders and VSCodium to Open In editor picker (#1392)


5 files changed, 229 insertions(+), 26 deletions(-)

apps/server/src/open.test.ts+39-2
...@@ -40,6 +40,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -40,6 +40,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
40 args: ["/tmp/workspace"],40 args: ["/tmp/workspace"],
41 });41 });
4242
43 const vscodeInsidersLaunch = yield* resolveEditorLaunch(
44 { cwd: "/tmp/workspace", editor: "vscode-insiders" },
45 "darwin",
46 );
47 assert.deepEqual(vscodeInsidersLaunch, {
48 command: "code-insiders",
49 args: ["/tmp/workspace"],
50 });
51
52 const vscodiumLaunch = yield* resolveEditorLaunch(
53 { cwd: "/tmp/workspace", editor: "vscodium" },
54 "darwin",
55 );
56 assert.deepEqual(vscodiumLaunch, {
57 command: "codium",
58 args: ["/tmp/workspace"],
59 });
60
43 const zedLaunch = yield* resolveEditorLaunch(61 const zedLaunch = yield* resolveEditorLaunch(
44 { cwd: "/tmp/workspace", editor: "zed" },62 { cwd: "/tmp/workspace", editor: "zed" },
45 "darwin",63 "darwin",
...@@ -80,6 +98,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {...@@ -80,6 +98,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
80 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],98 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
81 });99 });
82100
101 const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch(
102 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" },
103 "darwin",
104 );
105 assert.deepEqual(vscodeInsidersLineAndColumn, {
106 command: "code-insiders",
107 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
108 });
109
110 const vscodiumLineAndColumn = yield* resolveEditorLaunch(
111 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" },
112 "darwin",
113 );
114 assert.deepEqual(vscodiumLineAndColumn, {
115 command: "codium",
116 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
117 });
118
83 const zedLineAndColumn = yield* resolveEditorLaunch(119 const zedLineAndColumn = yield* resolveEditorLaunch(
84 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },120 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },
85 "darwin",121 "darwin",
...@@ -220,13 +256,14 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {...@@ -220,13 +256,14 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
220 const path = yield* Path.Path;256 const path = yield* Path.Path;
221 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });257 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
222258
223 yield* fs.writeFileString(path.join(dir, "cursor.CMD"), "@echo off\r\n");259 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
260 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
224 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");261 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
225 const editors = resolveAvailableEditors("win32", {262 const editors = resolveAvailableEditors("win32", {
226 PATH: dir,263 PATH: dir,
227 PATHEXT: ".COM;.EXE;.BAT;.CMD",264 PATHEXT: ".COM;.EXE;.BAT;.CMD",
228 });265 });
229 assert.deepEqual(editors, ["cursor", "file-manager"]);266 assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]);
230 }),267 }),
231 );268 );
232});269});
apps/server/src/open.ts+3-5
...@@ -39,10 +39,8 @@ interface CommandAvailabilityOptions {...@@ -39,10 +39,8 @@ interface CommandAvailabilityOptions {
3939
40const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;40const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
4141
42function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {42function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean {
43 return (43 return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target);
44 (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target)
45 );
46}44}
4745
48function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {46function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
...@@ -213,7 +211,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* (...@@ -213,7 +211,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* (
213 }211 }
214212
215 if (editorDef.command) {213 if (editorDef.command) {
216 return shouldUseGotoFlag(editorDef.id, input.cwd)214 return shouldUseGotoFlag(editorDef, input.cwd)
217 ? { command: editorDef.command, args: ["--goto", input.cwd] }215 ? { command: editorDef.command, args: ["--goto", input.cwd] }
218 : { command: editorDef.command, args: [input.cwd] };216 : { command: editorDef.command, args: [input.cwd] };
219 }217 }
apps/web/src/components/ChatView.browser.tsx+165-14
...@@ -356,6 +356,25 @@ function withProjectScripts(...@@ -356,6 +356,25 @@ function withProjectScripts(
356 };356 };
357}357}
358358
359function setDraftThreadWithoutWorktree(): void {
360 useComposerDraftStore.setState({
361 draftThreadsByThreadId: {
362 [THREAD_ID]: {
363 projectId: PROJECT_ID,
364 createdAt: NOW_ISO,
365 runtimeMode: "full-access",
366 interactionMode: "default",
367 branch: null,
368 worktreePath: null,
369 envMode: "local",
370 },
371 },
372 projectDraftThreadIdByProjectId: {
373 [PROJECT_ID]: THREAD_ID,
374 },
375 });
376}
377
359function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {378function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
360 const snapshot = createSnapshotForTargetUser({379 const snapshot = createSnapshotForTargetUser({
361 targetMessageId: "msg-user-plan-target" as MessageId,380 targetMessageId: "msg-user-plan-target" as MessageId,
...@@ -1011,30 +1030,162 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1011,30 +1030,162 @@ describe("ChatView timeline estimator parity (full app)", () => {
1011 );1030 );
10121031
1013 it("opens the project cwd for draft threads without a worktree path", async () => {1032 it("opens the project cwd for draft threads without a worktree path", async () => {
1014 useComposerDraftStore.setState({1033 setDraftThreadWithoutWorktree();
1015 draftThreadsByThreadId: {1034
1016 [THREAD_ID]: {1035 const mounted = await mountChatView({
1017 projectId: PROJECT_ID,1036 viewport: DEFAULT_VIEWPORT,
1018 createdAt: NOW_ISO,1037 snapshot: createDraftOnlySnapshot(),
1019 runtimeMode: "full-access",1038 configureFixture: (nextFixture) => {
1020 interactionMode: "default",1039 nextFixture.serverConfig = {
1021 branch: null,1040 ...nextFixture.serverConfig,
1022 worktreePath: null,1041 availableEditors: ["vscode"],
1023 envMode: "local",1042 };
1043 },
1044 });
1045
1046 try {
1047 const openButton = await waitForElement(
1048 () =>
1049 Array.from(document.querySelectorAll("button")).find(
1050 (button) => button.textContent?.trim() === "Open",
1051 ) as HTMLButtonElement | null,
1052 "Unable to find Open button.",
1053 );
1054 openButton.click();
1055
1056 await vi.waitFor(
1057 () => {
1058 const openRequest = wsRequests.find(
1059 (request) => request._tag === WS_METHODS.shellOpenInEditor,
1060 );
1061 expect(openRequest).toMatchObject({
1062 _tag: WS_METHODS.shellOpenInEditor,
1063 cwd: "/repo/project",
1064 editor: "vscode",
1065 });
1024 },1066 },
1067 { timeout: 8_000, interval: 16 },
1068 );
1069 } finally {
1070 await mounted.cleanup();
1071 }
1072 });
1073
1074 it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => {
1075 setDraftThreadWithoutWorktree();
1076
1077 const mounted = await mountChatView({
1078 viewport: DEFAULT_VIEWPORT,
1079 snapshot: createDraftOnlySnapshot(),
1080 configureFixture: (nextFixture) => {
1081 nextFixture.serverConfig = {
1082 ...nextFixture.serverConfig,
1083 availableEditors: ["vscode-insiders"],
1084 };
1025 },1085 },
1026 projectDraftThreadIdByProjectId: {1086 });
1027 [PROJECT_ID]: THREAD_ID,1087
1088 try {
1089 const openButton = await waitForElement(
1090 () =>
1091 Array.from(document.querySelectorAll("button")).find(
1092 (button) => button.textContent?.trim() === "Open",
1093 ) as HTMLButtonElement | null,
1094 "Unable to find Open button.",
1095 );
1096 openButton.click();
1097
1098 await vi.waitFor(
1099 () => {
1100 const openRequest = wsRequests.find(
1101 (request) => request._tag === WS_METHODS.shellOpenInEditor,
1102 );
1103 expect(openRequest).toMatchObject({
1104 _tag: WS_METHODS.shellOpenInEditor,
1105 cwd: "/repo/project",
1106 editor: "vscode-insiders",
1107 });
1108 },
1109 { timeout: 8_000, interval: 16 },
1110 );
1111 } finally {
1112 await mounted.cleanup();
1113 }
1114 });
1115
1116 it("filters the open picker menu and opens VSCodium from the menu", async () => {
1117 setDraftThreadWithoutWorktree();
1118
1119 const mounted = await mountChatView({
1120 viewport: DEFAULT_VIEWPORT,
1121 snapshot: createDraftOnlySnapshot(),
1122 configureFixture: (nextFixture) => {
1123 nextFixture.serverConfig = {
1124 ...nextFixture.serverConfig,
1125 availableEditors: ["vscode-insiders", "vscodium"],
1126 };
1028 },1127 },
1029 });1128 });
10301129
1130 try {
1131 const menuButton = await waitForElement(
1132 () => document.querySelector('button[aria-label="Copy options"]'),
1133 "Unable to find Open picker button.",
1134 );
1135 (menuButton as HTMLButtonElement).click();
1136
1137 await waitForElement(
1138 () =>
1139 Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) =>
1140 item.textContent?.includes("VS Code Insiders"),
1141 ) ?? null,
1142 "Unable to find VS Code Insiders menu item.",
1143 );
1144
1145 expect(
1146 Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) =>
1147 item.textContent?.includes("Zed"),
1148 ),
1149 ).toBe(false);
1150
1151 const vscodiumItem = await waitForElement(
1152 () =>
1153 Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) =>
1154 item.textContent?.includes("VSCodium"),
1155 ) ?? null,
1156 "Unable to find VSCodium menu item.",
1157 );
1158 (vscodiumItem as HTMLElement).click();
1159
1160 await vi.waitFor(
1161 () => {
1162 const openRequest = wsRequests.find(
1163 (request) => request._tag === WS_METHODS.shellOpenInEditor,
1164 );
1165 expect(openRequest).toMatchObject({
1166 _tag: WS_METHODS.shellOpenInEditor,
1167 cwd: "/repo/project",
1168 editor: "vscodium",
1169 });
1170 },
1171 { timeout: 8_000, interval: 16 },
1172 );
1173 } finally {
1174 await mounted.cleanup();
1175 }
1176 });
1177
1178 it("falls back to the first installed editor when the stored favorite is unavailable", async () => {
1179 localStorage.setItem("t3code:last-editor", "vscodium");
1180 setDraftThreadWithoutWorktree();
1181
1031 const mounted = await mountChatView({1182 const mounted = await mountChatView({
1032 viewport: DEFAULT_VIEWPORT,1183 viewport: DEFAULT_VIEWPORT,
1033 snapshot: createDraftOnlySnapshot(),1184 snapshot: createDraftOnlySnapshot(),
1034 configureFixture: (nextFixture) => {1185 configureFixture: (nextFixture) => {
1035 nextFixture.serverConfig = {1186 nextFixture.serverConfig = {
1036 ...nextFixture.serverConfig,1187 ...nextFixture.serverConfig,
1037 availableEditors: ["vscode"],1188 availableEditors: ["vscode-insiders"],
1038 };1189 };
1039 },1190 },
1040 });1191 });
...@@ -1057,7 +1208,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1057,7 +1208,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1057 expect(openRequest).toMatchObject({1208 expect(openRequest).toMatchObject({
1058 _tag: WS_METHODS.shellOpenInEditor,1209 _tag: WS_METHODS.shellOpenInEditor,
1059 cwd: "/repo/project",1210 cwd: "/repo/project",
1060 editor: "vscode",1211 editor: "vscode-insiders",
1061 });1212 });
1062 },1213 },
1063 { timeout: 8_000, interval: 16 },1214 { timeout: 8_000, interval: 16 },
apps/web/src/components/chat/OpenInPicker.tsx+10
...@@ -22,6 +22,16 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor...@@ -22,6 +22,16 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor
22 Icon: VisualStudioCode,22 Icon: VisualStudioCode,
23 value: "vscode",23 value: "vscode",
24 },24 },
25 {
26 label: "VS Code Insiders",
27 Icon: VisualStudioCode,
28 value: "vscode-insiders",
29 },
30 {
31 label: "VSCodium",
32 Icon: VisualStudioCode,
33 value: "vscodium",
34 },
25 {35 {
26 label: "Zed",36 label: "Zed",
27 Icon: Zed,37 Icon: Zed,
packages/contracts/src/editor.ts+12-5
...@@ -2,11 +2,18 @@ import { Schema } from "effect";...@@ -2,11 +2,18 @@ import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { TrimmedNonEmptyString } from "./baseSchemas";
33
4export const EDITORS = [4export const EDITORS = [
5 { id: "cursor", label: "Cursor", command: "cursor" },5 { id: "cursor", label: "Cursor", command: "cursor", supportsGoto: true },
6 { id: "vscode", label: "VS Code", command: "code" },6 { id: "vscode", label: "VS Code", command: "code", supportsGoto: true },
7 { id: "zed", label: "Zed", command: "zed" },7 {
8 { id: "antigravity", label: "Antigravity", command: "agy" },8 id: "vscode-insiders",
9 { id: "file-manager", label: "File Manager", command: null },9 label: "VS Code Insiders",
10 command: "code-insiders",
11 supportsGoto: true,
12 },
13 { id: "vscodium", label: "VSCodium", command: "codium", supportsGoto: true },
14 { id: "zed", label: "Zed", command: "zed", supportsGoto: false },
15 { id: "antigravity", label: "Antigravity", command: "agy", supportsGoto: false },
16 { id: "file-manager", label: "File Manager", command: null, supportsGoto: false },
10] as const;17] as const;
1118
12export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));19export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));