diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index f600a05028..ac356c6a3d 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -36,6 +36,16 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const windsurfLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "windsurf" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(windsurfLaunch, { + command: "windsurf", + args: ["/tmp/workspace"], + }); + const traeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "trae" }, "darwin", @@ -225,6 +235,16 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const windsurfLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "windsurf" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(windsurfLineAndColumn, { + command: "windsurf", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const traeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "trae" }, "darwin", @@ -411,6 +431,29 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }), ); + it.effect("falls back to surf when windsurf is not installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); + yield* fs.writeFileString(path.join(dir, "surf"), "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(path.join(dir, "surf"), 0o755); + + const result = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "windsurf" }, + "linux", + { + PATH: dir, + }, + ); + + assert.deepEqual(result, { + command: "surf", + args: ["/tmp/workspace"], + }); + }), + ); + it.effect("falls back to zeditor when zed is not installed", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -576,6 +619,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "windsurf.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, "aqua.CMD"), "@echo off\r\n"); @@ -595,6 +639,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { PATHEXT: ".COM;.EXE;.BAT;.CMD", }); assert.deepEqual(editors, [ + "windsurf", "trae", "kiro", "vscode-insiders", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc..b600006eb3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2102,6 +2102,55 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows Windsurf in the open picker menu and opens the project cwd with it", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["windsurf"], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + const menuButton = await waitForElement( + () => document.querySelector('button[aria-label="Copy options"]'), + "Unable to find Open picker button.", + ); + (menuButton as HTMLButtonElement).click(); + + const windsurfItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("Windsurf"), + ) ?? null, + "Unable to find Windsurf menu item.", + ); + (windsurfItem 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: "windsurf", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("filters the open picker menu and opens VSCodium from the menu", async () => { setDraftThreadWithoutWorktree(); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index b3211e1775..06b2a9b48f 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -199,6 +199,15 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( ); +export const WindsurfIcon: Icon = (props) => ( + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 6b5f2dacc2..5a094bf257 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -15,6 +15,7 @@ import { VisualStudioCode, VisualStudioCodeInsiders, VSCodium, + WindsurfIcon, Zed, } from "../Icons"; import { @@ -41,6 +42,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray