| 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,6 +186,7 @@ function createWindow(): BrowserWindow { |
| 186 | 186 | ||
| 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 | } |
| 124 | 124 | ||
| 125 | // Resolve the real default branch from the remote | 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 | 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; |
| 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 | const branches = result.stdout | 148 | 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 | } |
| 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 | 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 | <button | 169 | <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.branch | 201 | {activeThread.branch |
| 191 | ? envMode === "worktree" && !envLocked | 202 | ? envMode === "worktree" && !activeWorktreePath |
| 192 | ? `From ${activeThread.branch}` | 203 | ? `From ${activeThread.branch}` |
| 193 | : activeThread.branch | 204 | : 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 | <button | 235 | <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"; |
| 2 | import react from "@vitejs/plugin-react"; | 2 | import react from "@vitejs/plugin-react"; |
| 3 | import { defineConfig } from "vite"; | 3 | import { defineConfig } from "vite"; |
| 4 | 4 | ||
| 5 | const port = Number(process.env.PORT ?? 5173); | ||
| 6 | |||
| 5 | export default defineConfig({ | 7 | export 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 | }); |
| 44 | 45 | ||
| 45 | export const gitWorktreeSchema = z.object({ | 46 | export const gitWorktreeSchema = z.object({ |