| author | |
| committer | |
| log | 8324949b54c9aadc5d5db1b6cebbd10325a0ea79 |
| tree | 51a015237cd3b6296ea705ccaca237a53725b1e3 |
| parent | 807e3d18dec858c9694a595deaea4f9ac588db5f |
- 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 | 186 | |
| 187 | 187 | if (isDevelopment) { |
| 188 | 188 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); |
| 189 | window.webContents.openDevTools({ mode: "detach" }); | |
| 189 | 190 | } else { |
| 190 | 191 | if (!fs.existsSync(WEB_ENTRY)) { |
| 191 | 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 | 123 | } |
| 124 | 124 | |
| 125 | 125 | // 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 | 130 | const defaultBranch = |
| 128 | 131 | defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; |
| 129 | 132 | |
| 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 | 148 | const branches = result.stdout |
| 131 | 149 | .split("\n") |
| 132 | 150 | .map((line) => line.trim()) |
| 133 | 151 | .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 | }) | |
| 139 | 161 | .toSorted((a, b) => { |
| 140 | 162 | if (a.current !== b.current) return a.current ? -1 : 1; |
| 141 | 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 | 122 | return; |
| 123 | 123 | } |
| 124 | 124 | |
| 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 | 136 | checkoutMutation.mutate(branch.name, { |
| 126 | 137 | onSuccess: () => { |
| 127 | 138 | setThreadError(null); |
| ... | ... | @@ -150,9 +161,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 150 | 161 | return ( |
| 151 | 162 | <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1"> |
| 152 | 163 | <div className="flex items-center gap-2"> |
| 153 | {envLocked ? ( | |
| 164 | {envLocked || activeWorktreePath ? ( | |
| 154 | 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 | 167 | </span> |
| 157 | 168 | ) : ( |
| 158 | 169 | <button |
| ... | ... | @@ -188,7 +199,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 188 | 199 | > |
| 189 | 200 | <span className="max-w-[240px] truncate font-mono"> |
| 190 | 201 | {activeThread.branch |
| 191 | ? envMode === "worktree" && !envLocked | |
| 202 | ? envMode === "worktree" && !activeWorktreePath | |
| 192 | 203 | ? `From ${activeThread.branch}` |
| 193 | 204 | : activeThread.branch |
| 194 | 205 | : "Select branch"} |
| ... | ... | @@ -218,6 +229,8 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 218 | 229 | <div className="max-h-64 overflow-y-auto"> |
| 219 | 230 | {branches.map((branch) => { |
| 220 | 231 | const isSelected = branch.name === activeThread.branch; |
| 232 | const hasSecondaryWorktree = | |
| 233 | branch.worktreePath && branch.worktreePath !== activeProject.cwd; | |
| 221 | 234 | return ( |
| 222 | 235 | <button |
| 223 | 236 | key={branch.name} |
| ... | ... | @@ -230,9 +243,9 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 230 | 243 | onClick={() => selectBranch(branch)} |
| 231 | 244 | > |
| 232 | 245 | <span className="truncate">{branch.name}</span> |
| 233 | {(branch.current || branch.isDefault) && ( | |
| 246 | {(branch.current || branch.isDefault || hasSecondaryWorktree) && ( | |
| 234 | 247 | <span className="shrink-0 text-[10px] text-muted-foreground/45"> |
| 235 | {branch.current ? "current" : "default"} | |
| 248 | {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"} | |
| 236 | 249 | </span> |
| 237 | 250 | )} |
| 238 | 251 | </button> |
apps/web/vite.config.ts+10-1| ... | ... | @@ -2,6 +2,8 @@ import tailwindcss from "@tailwindcss/vite"; |
| 2 | 2 | import react from "@vitejs/plugin-react"; |
| 3 | 3 | import { defineConfig } from "vite"; |
| 4 | 4 | |
| 5 | const port = Number(process.env.PORT ?? 5173); | |
| 6 | ||
| 5 | 7 | export default defineConfig({ |
| 6 | 8 | plugins: [ |
| 7 | 9 | react({ |
| ... | ... | @@ -16,8 +18,15 @@ export default defineConfig({ |
| 16 | 18 | "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""), |
| 17 | 19 | }, |
| 18 | 20 | server: { |
| 19 | port: Number(process.env.PORT ?? 5173), | |
| 21 | port, | |
| 20 | 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 | 31 | build: { |
| 23 | 32 | outDir: "dist", |
packages/contracts/src/git.ts+1| ... | ... | @@ -40,6 +40,7 @@ export const gitBranchSchema = z.object({ |
| 40 | 40 | name: z.string().min(1), |
| 41 | 41 | current: z.boolean(), |
| 42 | 42 | isDefault: z.boolean(), |
| 43 | worktreePath: z.string().min(1).nullable(), | |
| 43 | 44 | }); |
| 44 | 45 | |
| 45 | 46 | export const gitWorktreeSchema = z.object({ |