diff --git a/change-logs/2026/06/10/fix-zed-new-window-per-worktree.md b/change-logs/2026/06/10/fix-zed-new-window-per-worktree.md new file mode 100644 index 00000000..27a2ecd5 --- /dev/null +++ b/change-logs/2026/06/10/fix-zed-new-window-per-worktree.md @@ -0,0 +1 @@ +Fixed "Open in → Zed" so each worktree opens in its own Zed window instead of swapping the project in the single running window. When the Zed CLI is available (on PATH or bundled in Zed.app), dev3 now launches it with the `-n` flag; it falls back to `open -a Zed` otherwise. diff --git a/src/bun/__tests__/rpc-handlers.test.ts b/src/bun/__tests__/rpc-handlers.test.ts index 52e99546..96272d4d 100644 --- a/src/bun/__tests__/rpc-handlers.test.ts +++ b/src/bun/__tests__/rpc-handlers.test.ts @@ -7081,3 +7081,73 @@ describe("handlers.createPullRequest", () => { ).rejects.toThrow("Task has no worktree"); }); }); + +// handlers.openInApp — launching external editors / Finder. +// Issue: Zed launched via `open -a Zed` reuses its window and swaps the +// project, so worktree B replaces worktree A. The Zed CLI's `-n` flag is +// required to give each worktree its own window. +describe("handlers.openInApp", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(existsSync).mockReturnValue(true); + }); + + it("opens a path in Finder via `open `", async () => { + await handlers.openInApp({ appName: "Finder", path: "/tmp/work" }); + expect(mockSpawn).toHaveBeenCalledWith(["open", "/tmp/work"], expect.anything()); + }); + + it("opens non-Zed editors via `open -a `", async () => { + await handlers.openInApp({ appName: "Visual Studio Code", path: "/tmp/work" }); + expect(mockSpawn).toHaveBeenCalledWith( + ["open", "-a", "Visual Studio Code", "/tmp/work"], + expect.anything(), + ); + }); + + it("opens Zed in a NEW window via the bundled Zed CLI `-n` flag", async () => { + // Only the bundled cli inside the app exists (no `zed` on PATH). + vi.mocked(existsSync).mockImplementation( + (p) => p === "/Applications/Zed.app/Contents/MacOS/cli", + ); + await handlers.openInApp({ appName: "Zed", path: "/tmp/work" }); + expect(mockSpawn).toHaveBeenCalledWith( + ["/Applications/Zed.app/Contents/MacOS/cli", "-n", "/tmp/work"], + expect.anything(), + ); + }); + + it("prefers a Zed CLI on PATH over the app bundle", async () => { + // All candidates exist → the first (PATH binary) wins. + vi.mocked(existsSync).mockReturnValue(true); + await handlers.openInApp({ appName: "Zed", path: "/tmp/work" }); + expect(mockSpawn).toHaveBeenCalledWith( + ["/usr/local/bin/zed", "-n", "/tmp/work"], + expect.anything(), + ); + }); + + it("falls back to `open -a Zed` when no Zed CLI is found", async () => { + vi.mocked(existsSync).mockReturnValue(false); + await handlers.openInApp({ appName: "Zed", path: "/tmp/work" }); + expect(mockSpawn).toHaveBeenCalledWith( + ["open", "-a", "Zed", "/tmp/work"], + expect.anything(), + ); + }); + + it("rejects relative paths and path traversal", async () => { + await expect( + handlers.openInApp({ appName: "Zed", path: "relative/path" }), + ).rejects.toThrow("Invalid path"); + await expect( + handlers.openInApp({ appName: "Zed", path: "/tmp/../etc/passwd" }), + ).rejects.toThrow("Invalid path"); + }); + + it("rejects app names containing a slash", async () => { + await expect( + handlers.openInApp({ appName: "../evil", path: "/tmp/work" }), + ).rejects.toThrow("Invalid app name"); + }); +}); diff --git a/src/bun/rpc-handlers/app-handlers.ts b/src/bun/rpc-handlers/app-handlers.ts index e392bebd..088f3451 100644 --- a/src/bun/rpc-handlers/app-handlers.ts +++ b/src/bun/rpc-handlers/app-handlers.ts @@ -635,6 +635,28 @@ async function getStuckPreparationThresholdMs(): Promise<{ ms: number }> { return { ms: STUCK_PREPARATION_FETCH_THRESHOLD_MS }; } +/** + * Locate the Zed CLI binary. Launching Zed via `open -a Zed ` reuses the + * already-running window and swaps its project, so opening worktree B replaces + * worktree A. The Zed CLI's `-n` flag is the only way to force a new window per + * worktree — but `-n` is a CLI flag `open` can't pass, so we must invoke the CLI + * directly. Prefer a binary on a common PATH location, then the `cli` bundled + * inside the app. Returns null if none is found (caller falls back to `open -a`). + */ +function resolveZedCli(): string | null { + const candidates = [ + "/usr/local/bin/zed", + join(homedir(), ".local/bin/zed"), + "/opt/homebrew/bin/zed", + "/Applications/Zed.app/Contents/MacOS/cli", + join(homedir(), "Applications/Zed.app/Contents/MacOS/cli"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + async function openInApp(params: { appName: string; path: string }): Promise { log.info("→ openInApp", { appName: params.appName, path: params.path }); if (!params.path.startsWith("/") || params.path.includes("..")) { @@ -647,6 +669,16 @@ async function openInApp(params: { appName: string; path: string }): Promise