Skip to content
Merged
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
1 change: 1 addition & 0 deletions change-logs/2026/06/10/fix-zed-new-window-per-worktree.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 70 additions & 0 deletions src/bun/__tests__/rpc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`", 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 <app> <path>`", 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");
});
});
32 changes: 32 additions & 0 deletions src/bun/rpc-handlers/app-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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<void> {
log.info("→ openInApp", { appName: params.appName, path: params.path });
if (!params.path.startsWith("/") || params.path.includes("..")) {
Expand All @@ -647,6 +669,16 @@ async function openInApp(params: { appName: string; path: string }): Promise<voi
spawn(["open", params.path], { stdout: "ignore", stderr: "ignore" });
return;
}
// Zed reuses its current window when opened via `open -a`; use the Zed CLI's
// `-n` flag to give each worktree its own window. Fall back to `open -a` when
// the CLI binary can't be located.
if (params.appName === "Zed") {
const zedCli = resolveZedCli();
if (zedCli) {
spawn([zedCli, "-n", params.path], { stdout: "ignore", stderr: "ignore" });
return;
}
}
spawn(["open", "-a", params.appName, params.path], { stdout: "ignore", stderr: "ignore" });
}

Expand Down
Loading