From 41e57c0b87446bf91ae04d694af9b1e28fea66c6 Mon Sep 17 00:00:00 2001 From: Ittai Zeidman Date: Wed, 10 Jun 2026 08:36:14 +0300 Subject: [PATCH] Open Zed in a new window per worktree Launching Zed via 'open -a Zed ' reuses the running window and swaps its project, so opening worktree B replaced worktree A. Special-case Zed in openInApp to invoke the Zed CLI with '-n' (forces a new workspace window), resolving the binary from PATH or the bundled Zed.app/Contents/MacOS/cli, and fall back to 'open -a Zed' when no CLI is found. --- .../06/10/fix-zed-new-window-per-worktree.md | 1 + src/bun/__tests__/rpc-handlers.test.ts | 70 +++++++++++++++++++ src/bun/rpc-handlers/app-handlers.ts | 32 +++++++++ 3 files changed, 103 insertions(+) create mode 100644 change-logs/2026/06/10/fix-zed-new-window-per-worktree.md 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