authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 15:22:17-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 15:22:17-08:00
log8324949b54c9aadc5d5db1b6cebbd10325a0ea79
tree51a015237cd3b6296ea705ccaca237a53725b1e3
parent807e3d18dec858c9694a595deaea4f9ac588db5f

Enrich branches with worktree paths, fix toolbar display, and improve HMR config

- Add worktreePath to gitBranchSchema and populate from `git worktree list --porcelain` - Redirect to existing worktree when selecting a branch that's already checked out (instead of failing with "already used by worktree") - Distinguish main worktree (project cwd) from secondary worktrees in selection logic - Fix toolbar to show three states: Local, Worktree (existing), New worktree (pending) - Add explicit protocol/host to Vite HMR config for reliable Electron WebSocket - Auto-open DevTools in dev mode for HMR debugging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

5 files changed, 58 insertions(+), 12 deletions(-)

apps/desktop/src/main.ts+1
......@@ -186,6 +186,7 @@ function createWindow(): BrowserWindow {
186186
187187 if (isDevelopment) {
188188 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
189 window.webContents.openDevTools({ mode: "detach" });
189190 } else {
190191 if (!fs.existsSync(WEB_ENTRY)) {
191192 throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
apps/server/src/git.ts+28-6
......@@ -123,19 +123,41 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
123123 }
124124
125125 // Resolve the real default branch from the remote
126 const defaultRef = await runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000);
126 const [defaultRef, worktreeList] = await Promise.all([
127 runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),
128 runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),
129 ]);
127130 const defaultBranch =
128131 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
129132
133 // Build branch-name → worktree-path map from porcelain output.
134 const worktreeMap = new Map<string, string>();
135 if (worktreeList.code === 0) {
136 let currentPath: string | null = null;
137 for (const line of worktreeList.stdout.split("\n")) {
138 if (line.startsWith("worktree ")) {
139 currentPath = line.slice("worktree ".length);
140 } else if (line.startsWith("branch refs/heads/") && currentPath) {
141 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
142 } else if (line === "") {
143 currentPath = null;
144 }
145 }
146 }
147
130148 const branches = result.stdout
131149 .split("\n")
132150 .map((line) => line.trim())
133151 .filter((line) => line.length > 0)
134 .map((line) => ({
135 name: line.replace(/^[*+]\s+/, ""),
136 current: line.startsWith("* "),
137 isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch,
138 }))
152 .map((line) => {
153 const name = line.replace(/^[*+]\s+/, "");
154 return {
155 name,
156 current: line.startsWith("* "),
157 isDefault: name === defaultBranch,
158 worktreePath: worktreeMap.get(name) ?? null,
159 };
160 })
139161 .toSorted((a, b) => {
140162 if (a.current !== b.current) return a.current ? -1 : 1;
141163 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
apps/web/src/components/BranchToolbar.tsx+18-5
......@@ -122,6 +122,17 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
122122 return;
123123 }
124124
125 // If the branch already lives in a secondary worktree, point the thread
126 // there instead of trying to checkout (which git would reject).
127 // Ignore the main worktree (project cwd) — that's just a normal local checkout.
128 const isSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject?.cwd;
129 if (isSecondaryWorktree) {
130 setThreadError(null);
131 setThreadBranch(branch.name, branch.worktreePath);
132 setIsBranchMenuOpen(false);
133 return;
134 }
135
125136 checkoutMutation.mutate(branch.name, {
126137 onSuccess: () => {
127138 setThreadError(null);
......@@ -150,9 +161,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
150161 return (
151162 <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">
152163 <div className="flex items-center gap-2">
153 {envLocked ? (
164 {envLocked || activeWorktreePath ? (
154165 <span className="inline-flex items-center gap-1.5 px-1 text-[12px] text-muted-foreground/55">
155 {activeThread.worktreePath ? "Worktree" : "Local"}
166 {activeWorktreePath ? "Worktree" : "Local"}
156167 </span>
157168 ) : (
158169 <button
......@@ -188,7 +199,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
188199 >
189200 <span className="max-w-[240px] truncate font-mono">
190201 {activeThread.branch
191 ? envMode === "worktree" && !envLocked
202 ? envMode === "worktree" && !activeWorktreePath
192203 ? `From ${activeThread.branch}`
193204 : activeThread.branch
194205 : "Select branch"}
......@@ -218,6 +229,8 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
218229 <div className="max-h-64 overflow-y-auto">
219230 {branches.map((branch) => {
220231 const isSelected = branch.name === activeThread.branch;
232 const hasSecondaryWorktree =
233 branch.worktreePath && branch.worktreePath !== activeProject.cwd;
221234 return (
222235 <button
223236 key={branch.name}
......@@ -230,9 +243,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
230243 onClick={() => selectBranch(branch)}
231244 >
232245 <span className="truncate">{branch.name}</span>
233 {(branch.current || branch.isDefault) && (
246 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
234247 <span className="shrink-0 text-[10px] text-muted-foreground/45">
235 {branch.current ? "current" : "default"}
248 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
236249 </span>
237250 )}
238251 </button>
apps/web/vite.config.ts+10-1
......@@ -2,6 +2,8 @@ import tailwindcss from "@tailwindcss/vite";
22import react from "@vitejs/plugin-react";
33import { defineConfig } from "vite";
44
5const port = Number(process.env.PORT ?? 5173);
6
57export default defineConfig({
68 plugins: [
79 react({
......@@ -16,8 +18,15 @@ export default defineConfig({
1618 "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
1719 },
1820 server: {
19 port: Number(process.env.PORT ?? 5173),
21 port,
2022 strictPort: true,
23 hmr: {
24 // Explicit config so Vite's HMR WebSocket connects reliably
25 // inside Electron's BrowserWindow. Vite 8 uses console.debug for
26 // connection logs — enable "Verbose" in DevTools to see them.
27 protocol: "ws",
28 host: "localhost",
29 },
2130 },
2231 build: {
2332 outDir: "dist",
packages/contracts/src/git.ts+1
......@@ -40,6 +40,7 @@ export const gitBranchSchema = z.object({
4040 name: z.string().min(1),
4141 current: z.boolean(),
4242 isDefault: z.boolean(),
43 worktreePath: z.string().min(1).nullable(),
4344});
4445
4546export const gitWorktreeSchema = z.object({