Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions apps/server/src/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"]);
}),
);
});
8 changes: 3 additions & 5 deletions apps/server/src/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] };
}
Expand Down
179 changes: 165 additions & 14 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
};
},
});
Expand All @@ -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 },
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor
Icon: VisualStudioCode,
value: "vscode",
},
{
label: "VS Code Insiders",
Icon: VisualStudioCode,
value: "vscode-insiders",
},
{
label: "VSCodium",
Icon: VisualStudioCode,
value: "vscodium",
},
{
label: "Zed",
Icon: Zed,
Expand Down
17 changes: 12 additions & 5 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ import { Schema } from "effect";
import { TrimmedNonEmptyString } from "./baseSchemas";

export const EDITORS = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "antigravity", label: "Antigravity", command: "agy" },
{ id: "file-manager", label: "File Manager", command: null },
{ id: "cursor", label: "Cursor", command: "cursor", supportsGoto: true },
{ id: "vscode", label: "VS Code", command: "code", supportsGoto: true },
{
id: "vscode-insiders",
label: "VS Code Insiders",
command: "code-insiders",
supportsGoto: true,
},
{ id: "vscodium", label: "VSCodium", command: "codium", supportsGoto: true },
{ id: "zed", label: "Zed", command: "zed", supportsGoto: false },
{ id: "antigravity", label: "Antigravity", command: "agy", supportsGoto: false },
{ id: "file-manager", label: "File Manager", command: null, supportsGoto: false },
] as const;

export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
Expand Down