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 {...@@ -186,6 +186,7 @@ function createWindow(): BrowserWindow {
186186
187 if (isDevelopment) {187 if (isDevelopment) {
188 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);188 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
189 window.webContents.openDevTools({ mode: "detach" });
189 } else {190 } else {
190 if (!fs.existsSync(WEB_ENTRY)) {191 if (!fs.existsSync(WEB_ENTRY)) {
191 throw new Error(`Web bundle missing at ${WEB_ENTRY}`);192 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...@@ -123,19 +123,41 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
123 }123 }
124124
125 // Resolve the real default branch from the remote125 // 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 ]);
127 const defaultBranch =130 const defaultBranch =
128 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;131 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
130 const branches = result.stdout148 const branches = result.stdout
131 .split("\n")149 .split("\n")
132 .map((line) => line.trim())150 .map((line) => line.trim())
133 .filter((line) => line.length > 0)151 .filter((line) => line.length > 0)
134 .map((line) => ({152 .map((line) => {
135 name: line.replace(/^[*+]\s+/, ""),153 const name = line.replace(/^[*+]\s+/, "");
136 current: line.startsWith("* "),154 return {
137 isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch,155 name,
138 }))156 current: line.startsWith("* "),
157 isDefault: name === defaultBranch,
158 worktreePath: worktreeMap.get(name) ?? null,
159 };
160 })
139 .toSorted((a, b) => {161 .toSorted((a, b) => {
140 if (a.current !== b.current) return a.current ? -1 : 1;162 if (a.current !== b.current) return a.current ? -1 : 1;
141 if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;163 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...@@ -122,6 +122,17 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
122 return;122 return;
123 }123 }
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
125 checkoutMutation.mutate(branch.name, {136 checkoutMutation.mutate(branch.name, {
126 onSuccess: () => {137 onSuccess: () => {
127 setThreadError(null);138 setThreadError(null);
...@@ -150,9 +161,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -150,9 +161,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
150 return (161 return (
151 <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">162 <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">
152 <div className="flex items-center gap-2">163 <div className="flex items-center gap-2">
153 {envLocked ? (164 {envLocked || activeWorktreePath ? (
154 <span className="inline-flex items-center gap-1.5 px-1 text-[12px] text-muted-foreground/55">165 <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"}
156 </span>167 </span>
157 ) : (168 ) : (
158 <button169 <button
...@@ -188,7 +199,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -188,7 +199,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
188 >199 >
189 <span className="max-w-[240px] truncate font-mono">200 <span className="max-w-[240px] truncate font-mono">
190 {activeThread.branch201 {activeThread.branch
191 ? envMode === "worktree" && !envLocked202 ? envMode === "worktree" && !activeWorktreePath
192 ? `From ${activeThread.branch}`203 ? `From ${activeThread.branch}`
193 : activeThread.branch204 : activeThread.branch
194 : "Select branch"}205 : "Select branch"}
...@@ -218,6 +229,8 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -218,6 +229,8 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
218 <div className="max-h-64 overflow-y-auto">229 <div className="max-h-64 overflow-y-auto">
219 {branches.map((branch) => {230 {branches.map((branch) => {
220 const isSelected = branch.name === activeThread.branch;231 const isSelected = branch.name === activeThread.branch;
232 const hasSecondaryWorktree =
233 branch.worktreePath && branch.worktreePath !== activeProject.cwd;
221 return (234 return (
222 <button235 <button
223 key={branch.name}236 key={branch.name}
...@@ -230,9 +243,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -230,9 +243,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
230 onClick={() => selectBranch(branch)}243 onClick={() => selectBranch(branch)}
231 >244 >
232 <span className="truncate">{branch.name}</span>245 <span className="truncate">{branch.name}</span>
233 {(branch.current || branch.isDefault) && (246 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
234 <span className="shrink-0 text-[10px] text-muted-foreground/45">247 <span className="shrink-0 text-[10px] text-muted-foreground/45">
235 {branch.current ? "current" : "default"}248 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
236 </span>249 </span>
237 )}250 )}
238 </button>251 </button>
apps/web/vite.config.ts+10-1
...@@ -2,6 +2,8 @@ import tailwindcss from "@tailwindcss/vite";...@@ -2,6 +2,8 @@ import tailwindcss from "@tailwindcss/vite";
2import react from "@vitejs/plugin-react";2import react from "@vitejs/plugin-react";
3import { defineConfig } from "vite";3import { defineConfig } from "vite";
44
5const port = Number(process.env.PORT ?? 5173);
6
5export default defineConfig({7export default defineConfig({
6 plugins: [8 plugins: [
7 react({9 react({
...@@ -16,8 +18,15 @@ export default defineConfig({...@@ -16,8 +18,15 @@ export default defineConfig({
16 "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),18 "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
17 },19 },
18 server: {20 server: {
19 port: Number(process.env.PORT ?? 5173),21 port,
20 strictPort: true,22 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 },
21 },30 },
22 build: {31 build: {
23 outDir: "dist",32 outDir: "dist",
packages/contracts/src/git.ts+1
...@@ -40,6 +40,7 @@ export const gitBranchSchema = z.object({...@@ -40,6 +40,7 @@ export const gitBranchSchema = z.object({
40 name: z.string().min(1),40 name: z.string().min(1),
41 current: z.boolean(),41 current: z.boolean(),
42 isDefault: z.boolean(),42 isDefault: z.boolean(),
43 worktreePath: z.string().min(1).nullable(),
43});44});
4445
45export const gitWorktreeSchema = z.object({46export const gitWorktreeSchema = z.object({