From c13e9f89a977518a07426a68df48603127fc2ec7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Feb 2026 23:58:27 -0800 Subject: [PATCH] Restore git toolbar via websocket backend Co-authored-by: codex --- apps/server/src/git.test.ts | 423 ++++++++++++++++++++++ apps/server/src/git.ts | 207 +++++++++++ apps/server/src/wsServer.test.ts | 26 ++ apps/server/src/wsServer.ts | 26 ++ apps/server/tsconfig.json | 2 +- apps/web/src/App.tsx | 2 + apps/web/src/components/BranchToolbar.tsx | 312 ++++++++++++++++ apps/web/src/components/ChatView.tsx | 53 ++- apps/web/src/components/Sidebar.tsx | 2 + apps/web/src/store.test.ts | 2 + apps/web/src/store.ts | 16 + apps/web/src/wsNativeApi.ts | 10 + packages/contracts/src/ws.ts | 8 + 13 files changed, 1087 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/git.test.ts create mode 100644 apps/server/src/git.ts create mode 100644 apps/web/src/components/BranchToolbar.tsx diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd73725c03240c5079dd94178c3684fb3a409133 --- /dev/null +++ b/apps/server/src/git.test.ts @@ -0,0 +1,423 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + checkoutGitBranch, + createGitBranch, + createGitWorktree, + initGitRepo, + listGitBranches, + removeGitWorktree, + runTerminalCommand, +} from "./git"; + +// ── Helpers ── + +/** Run a raw git command for test setup (not under test). */ +async function git(cwd: string, command: string): Promise { + const result = await runTerminalCommand({ + command: `git ${command}`, + cwd, + timeoutMs: 10_000, + }); + if (result.code !== 0) { + throw new Error(`git ${command} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +/** Create a disposable temp directory that cleans up automatically. */ +async function makeTmpDir() { + const dir = await mkdtemp(path.join(tmpdir(), "git-test-")); + return { + path: dir, + [Symbol.asyncDispose]: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +/** Create a repo with an initial commit so branches work. */ +async function initRepoWithCommit(cwd: string): Promise { + await initGitRepo({ cwd }); + await git(cwd, "config user.email 'test@test.com'"); + await git(cwd, "config user.name 'Test'"); + await writeFile(path.join(cwd, "README.md"), "# test\n"); + await git(cwd, "add ."); + await git(cwd, "commit -m 'initial commit'"); +} + +// ── Tests ── + +describe("git integration", () => { + // ── initGitRepo ── + + describe("initGitRepo", () => { + it("creates a valid git repo", async () => { + await using tmp = await makeTmpDir(); + await initGitRepo({ cwd: tmp.path }); + expect(existsSync(path.join(tmp.path, ".git"))).toBe(true); + }); + + it("listGitBranches reports isRepo: true after init + commit", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.isRepo).toBe(true); + expect(result.branches.length).toBeGreaterThanOrEqual(1); + }); + }); + + // ── listGitBranches ── + + describe("listGitBranches", () => { + it("returns isRepo: false for non-git directory", async () => { + await using tmp = await makeTmpDir(); + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.isRepo).toBe(false); + expect(result.branches).toEqual([]); + }); + + it("returns the current branch with current: true", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const result = await listGitBranches({ cwd: tmp.path }); + const current = result.branches.find((b) => b.current); + expect(current).toBeDefined(); + expect(current!.current).toBe(true); + }); + + it("sorts current branch first", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "aaa-first-alpha" }); + await createGitBranch({ cwd: tmp.path, branch: "zzz-last" }); + + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches[0]!.current).toBe(true); + }); + + it("lists multiple branches after creating them", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "feature-a" }); + await createGitBranch({ cwd: tmp.path, branch: "feature-b" }); + + const result = await listGitBranches({ cwd: tmp.path }); + const names = result.branches.map((b) => b.name); + expect(names).toContain("feature-a"); + expect(names).toContain("feature-b"); + }); + + it("isDefault is false when no remote exists", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches.every((b) => b.isDefault === false)).toBe(true); + }); + }); + + // ── checkoutGitBranch ── + + describe("checkoutGitBranch", () => { + it("checks out an existing branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "feature" }); + + await checkoutGitBranch({ cwd: tmp.path, branch: "feature" }); + + const result = await listGitBranches({ cwd: tmp.path }); + const current = result.branches.find((b) => b.current); + expect(current!.name).toBe("feature"); + }); + + it("throws when branch does not exist", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow(); + }); + + it("throws when checkout would overwrite uncommitted changes", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "other" }); + + // Create a conflicting change: modify README on current branch + await writeFile(path.join(tmp.path, "README.md"), "modified\n"); + await git(tmp.path, "add README.md"); + + // First, checkout other branch cleanly + await git(tmp.path, "stash"); + await checkoutGitBranch({ cwd: tmp.path, branch: "other" }); + await writeFile(path.join(tmp.path, "README.md"), "other content\n"); + await git(tmp.path, "add ."); + await git(tmp.path, "commit -m 'other change'"); + + // Go back to default branch + const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => !b.current, + )!.name; + await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); + + // Make uncommitted changes to the same file + await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n"); + + // Checkout should fail due to uncommitted changes + await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow(); + }); + }); + + // ── createGitBranch ── + + describe("createGitBranch", () => { + it("creates a new branch visible in listGitBranches", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "new-feature" }); + + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); + }); + + it("throws when branch already exists", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "dupe" }); + await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow(); + }); + }); + + // ── createGitWorktree + removeGitWorktree ── + + describe("createGitWorktree", () => { + it("creates a worktree with a new branch from the base branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "worktree-out"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + const result = await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-branch", + path: wtPath, + }); + + expect(result.worktree.path).toBe(wtPath); + expect(result.worktree.branch).toBe("wt-branch"); + expect(existsSync(wtPath)).toBe(true); + expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); + + // Clean up worktree before tmp dir disposal + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + + it("worktree has the new branch checked out", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-check-dir"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-check", + path: wtPath, + }); + + // Verify the worktree is on the new branch + const branchOutput = await git(wtPath, "branch --show-current"); + expect(branchOutput).toBe("wt-check"); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + + it("throws when new branch name already exists", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "existing" }); + + const wtPath = path.join(tmp.path, "wt-conflict"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await expect( + createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "existing", + path: wtPath, + }), + ).rejects.toThrow(); + }); + + it("listGitBranches from worktree cwd reports worktree branch as current", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-list-dir"); + const mainBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: mainBranch, + newBranch: "wt-list", + path: wtPath, + }); + + // listGitBranches from the worktree should show wt-list as current + const wtBranches = await listGitBranches({ cwd: wtPath }); + expect(wtBranches.isRepo).toBe(true); + const wtCurrent = wtBranches.branches.find((b) => b.current); + expect(wtCurrent!.name).toBe("wt-list"); + + // Main repo should still show the original branch as current + const mainBranches = await listGitBranches({ cwd: tmp.path }); + const mainCurrent = mainBranches.branches.find((b) => b.current); + expect(mainCurrent!.name).toBe(mainBranch); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + + it("removeGitWorktree cleans up the worktree", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-remove-dir"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-remove", + path: wtPath, + }); + expect(existsSync(wtPath)).toBe(true); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + expect(existsSync(wtPath)).toBe(false); + }); + }); + + // ── Full flow: local branch checkout ── + + describe("full flow: local branch checkout", () => { + it("init → commit → create branch → checkout → verify current", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "feature-login" }); + await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" }); + + const result = await listGitBranches({ cwd: tmp.path }); + const current = result.branches.find((b) => b.current); + expect(current!.name).toBe("feature-login"); + }); + }); + + // ── Full flow: worktree creation from base branch ── + + describe("full flow: worktree creation", () => { + it("creates worktree with new branch from current branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + const wtPath = path.join(tmp.path, "my-worktree"); + const result = await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "feature-wt", + path: wtPath, + }); + + // Worktree exists + expect(existsSync(result.worktree.path)).toBe(true); + + // Main repo still on original branch + const mainBranches = await listGitBranches({ cwd: tmp.path }); + const mainCurrent = mainBranches.branches.find((b) => b.current); + expect(mainCurrent!.name).toBe(currentBranch); + + // Worktree is on the new branch + const wtBranch = await git(wtPath, "branch --show-current"); + expect(wtBranch).toBe("feature-wt"); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + }); + + // ── Full flow: thread switching simulation ── + + describe("full flow: thread switching (checkout toggling)", () => { + it("checkout a → checkout b → checkout a → current matches", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "branch-a" }); + await createGitBranch({ cwd: tmp.path, branch: "branch-b" }); + + // Simulate switching to thread A's branch + await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); + let branches = await listGitBranches({ cwd: tmp.path }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); + + // Simulate switching to thread B's branch + await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" }); + branches = await listGitBranches({ cwd: tmp.path }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); + + // Switch back to thread A + await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); + branches = await listGitBranches({ cwd: tmp.path }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); + }); + }); + + // ── Full flow: checkout conflict ── + + describe("full flow: checkout conflict", () => { + it("uncommitted changes prevent checkout to a diverged branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "diverged" }); + + // Make diverged branch have different file content + await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" }); + await writeFile(path.join(tmp.path, "README.md"), "diverged content\n"); + await git(tmp.path, "add ."); + await git(tmp.path, "commit -m 'diverge'"); + + // Actually, let's just get back to the initial branch explicitly + const allBranches = await listGitBranches({ cwd: tmp.path }); + const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; + await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); + + // Make local uncommitted changes to the same file + await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n"); + + // Attempt checkout should fail + await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow(); + + // Current branch should still be the initial one + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); + }); + }); +}); diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts new file mode 100644 index 0000000000000000000000000000000000000000..9273672834af3906977136dff6efdd9dc3b979c6 --- /dev/null +++ b/apps/server/src/git.ts @@ -0,0 +1,207 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; + +import type { + GitCheckoutInput, + GitCreateBranchInput, + GitCreateWorktreeInput, + GitCreateWorktreeResult, + GitInitInput, + GitListBranchesInput, + GitListBranchesResult, + GitRemoveWorktreeInput, + TerminalCommandInput, + TerminalCommandResult, +} from "@t3tools/contracts"; + +function escapeSingleQuotes(value: string): string { + return value.replace(/'/g, "'\\''"); +} + +export async function runTerminalCommand( + input: TerminalCommandInput, +): Promise { + const shellPath = + process.platform === "win32" + ? (process.env.ComSpec ?? "cmd.exe") + : (process.env.SHELL ?? "/bin/sh"); + + const args = + process.platform === "win32" + ? ["/d", "/s", "/c", input.command] + : ["-lc", input.command]; + + return new Promise((resolve, reject) => { + const child = spawn(shellPath, args, { + cwd: input.cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, 1_000).unref(); + }, input.timeoutMs ?? 30_000); + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolve({ + stdout, + stderr, + code: code ?? null, + signal: signal ?? null, + timedOut, + }); + }); + }); +} + +export async function listGitBranches( + input: GitListBranchesInput, +): Promise { + const result = await runTerminalCommand({ + command: "git branch --no-color", + cwd: input.cwd, + timeoutMs: 10_000, + }); + + if (result.code !== 0) { + const stderr = result.stderr.trim(); + if (stderr.includes("not a git repository")) { + return { branches: [], isRepo: false }; + } + throw new Error(stderr || "git branch failed"); + } + + // Resolve the real default branch from the remote + const defaultRef = await runTerminalCommand({ + command: "git symbolic-ref refs/remotes/origin/HEAD", + cwd: input.cwd, + timeoutMs: 5_000, + }); + const defaultBranch = + defaultRef.code === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const branches = result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => ({ + name: line.replace(/^[*+]\s+/, ""), + current: line.startsWith("* "), + isDefault: line.replace(/^[*+]\s+/, "") === defaultBranch, + })) + .toSorted((a, b) => { + if (a.current !== b.current) return a.current ? -1 : 1; + if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return { branches, isRepo: true }; +} + +export async function createGitWorktree( + input: GitCreateWorktreeInput, +): Promise { + const sanitizedBranch = input.newBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = + input.path ?? + path.join(input.cwd, "..", `${repoName}-worktrees`, sanitizedBranch); + + // Create a new branch from the base branch in a new worktree + const result = await runTerminalCommand({ + command: `git worktree add -b '${escapeSingleQuotes(input.newBranch)}' '${escapeSingleQuotes(worktreePath)}' '${escapeSingleQuotes(input.branch)}'`, + cwd: input.cwd, + timeoutMs: 30_000, + }); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git worktree add failed"); + } + + return { + worktree: { + path: worktreePath, + branch: input.newBranch, + }, + }; +} + +export async function removeGitWorktree( + input: GitRemoveWorktreeInput, +): Promise { + const result = await runTerminalCommand({ + command: `git worktree remove '${escapeSingleQuotes(input.path)}'`, + cwd: input.cwd, + timeoutMs: 15_000, + }); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git worktree remove failed"); + } +} + +export async function createGitBranch( + input: GitCreateBranchInput, +): Promise { + const result = await runTerminalCommand({ + command: `git branch '${escapeSingleQuotes(input.branch)}'`, + cwd: input.cwd, + timeoutMs: 10_000, + }); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git branch create failed"); + } +} + +export async function checkoutGitBranch( + input: GitCheckoutInput, +): Promise { + const result = await runTerminalCommand({ + command: `git checkout '${escapeSingleQuotes(input.branch)}'`, + cwd: input.cwd, + timeoutMs: 10_000, + }); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git checkout failed"); + } +} + +export async function initGitRepo(input: GitInitInput): Promise { + const result = await runTerminalCommand({ + command: "git init", + cwd: input.cwd, + timeoutMs: 10_000, + }); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git init failed"); + } +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ef5cb1b96311f7ffcce88b60cfeaacdb60b00491..770d1ccdb94a6f7f24dde178a71702d163232d0b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -281,6 +281,32 @@ describe("WebSocket Server", () => { expect(afterRemove.result).toEqual([]); }); + it("supports git methods over websocket", async () => { + const repoCwd = makeTempDir("t3code-ws-git-project-"); + + server = createTestServer({ cwd: "/test" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const beforeInit = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: repoCwd }); + expect(beforeInit.error).toBeUndefined(); + expect(beforeInit.result).toEqual({ branches: [], isRepo: false }); + + const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: repoCwd }); + expect(initResponse.error).toBeUndefined(); + + const afterInit = await sendRequest(ws, WS_METHODS.gitListBranches, { + cwd: repoCwd, + }); + expect(afterInit.error).toBeUndefined(); + expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true); + }); + it("prunes missing projects on startup", async () => { const stateDir = makeTempDir("t3code-ws-prune-state-"); const existing = makeTempDir("t3code-ws-existing-project-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ae9dc7ca687ea5ddf8b4987bc42bec1a77b4b6a9..97c35f1660ffec0608b31215023faad4cc36db0e 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -19,6 +19,14 @@ import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; import { ProjectRegistry } from "./projectRegistry"; import { ProviderManager } from "./providerManager"; +import { + checkoutGitBranch, + createGitBranch, + createGitWorktree, + initGitRepo, + listGitBranches, + removeGitWorktree, +} from "./git"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -304,6 +312,24 @@ export function createServer(options: ServerOptions) { return undefined; } + case WS_METHODS.gitListBranches: + return listGitBranches(request.params as never); + + case WS_METHODS.gitCreateWorktree: + return createGitWorktree(request.params as never); + + case WS_METHODS.gitRemoveWorktree: + return removeGitWorktree(request.params as never); + + case WS_METHODS.gitCreateBranch: + return createGitBranch(request.params as never); + + case WS_METHODS.gitCheckout: + return checkoutGitBranch(request.params as never); + + case WS_METHODS.gitInit: + return initGitRepo(request.params as never); + case WS_METHODS.serverGetConfig: return { cwd }; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index d6c715e1c11c0b599c5f813e885059683eb4ac4a..629a48bfc7dcfc22e8152b134a0e29e7a9ba4642 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node"], - "lib": ["ES2023"] + "lib": ["ES2023", "esnext.disposable"] }, "include": ["src", "tsup.config.ts"] } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e12d563ab92d89ea62c4844ec0ecb8a527e80d4d..969f55a04c41283c84963548080bf1ac84b1e446 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -82,6 +82,8 @@ function AutoProjectBootstrap() { events: [], error: null, createdAt: new Date().toISOString(), + branch: null, + worktreePath: null, }, }); }); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7cd2e25a4a206558cb3fb121f5820fba4c783c3 --- /dev/null +++ b/apps/web/src/components/BranchToolbar.tsx @@ -0,0 +1,312 @@ +import type { GitBranch } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { readNativeApi } from "../session-logic"; +import { useStore } from "../store"; + +interface BranchToolbarProps { + envMode: "local" | "worktree"; + onEnvModeChange: (mode: "local" | "worktree") => void; + envLocked: boolean; +} + +export default function BranchToolbar({ + envMode, + onEnvModeChange, + envLocked, +}: BranchToolbarProps) { + const { state, dispatch } = useStore(); + const api = useMemo(() => readNativeApi(), []); + + const [branches, setBranches] = useState([]); + const [isRepo, setIsRepo] = useState(false); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); + const [isCreatingBranch, setIsCreatingBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(""); + const [isInitializingRepo, setIsInitializingRepo] = useState(false); + const branchMenuRef = useRef(null); + + const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); + const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); + const branchCwd = activeThread?.worktreePath ?? activeProject?.cwd; + + const loadBranches = useCallback(async () => { + if (!api || !branchCwd) return; + setIsLoadingBranches(true); + try { + const result = await api.git.listBranches({ cwd: branchCwd }); + setIsRepo(result.isRepo); + setBranches(result.branches); + } catch (error) { + setIsRepo(false); + setBranches([]); + if (activeThread) { + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error: + error instanceof Error ? error.message : "Failed to load git branches.", + }); + } + } finally { + setIsLoadingBranches(false); + } + }, [activeThread, api, branchCwd, dispatch]); + + useEffect(() => { + void loadBranches(); + }, [loadBranches]); + + // Keep thread branch synced to git current branch for local threads. + useEffect(() => { + if (!activeThread || activeThread.worktreePath) return; + const current = branches.find((branch) => branch.current); + if (!current) return; + if (current.name === activeThread.branch) return; + dispatch({ + type: "SET_THREAD_BRANCH", + threadId: activeThread.id, + branch: current.name, + worktreePath: null, + }); + }, [activeThread, branches, dispatch]); + + useEffect(() => { + if (!isBranchMenuOpen) return; + const handleClickOutside = (event: MouseEvent) => { + if (!branchMenuRef.current) return; + if ( + event.target instanceof Node && + !branchMenuRef.current.contains(event.target) + ) { + setIsBranchMenuOpen(false); + } + }; + window.addEventListener("mousedown", handleClickOutside); + return () => { + window.removeEventListener("mousedown", handleClickOutside); + }; + }, [isBranchMenuOpen]); + + const setThreadError = (error: string | null) => { + if (!activeThread) return; + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error, + }); + }; + + const setThreadBranch = (branch: string | null, worktreePath: string | null) => { + if (!activeThread) return; + dispatch({ + type: "SET_THREAD_BRANCH", + threadId: activeThread.id, + branch, + worktreePath, + }); + }; + + const initializeRepo = async () => { + if (!api || !branchCwd || !activeThread || isInitializingRepo) return; + setIsInitializingRepo(true); + try { + await api.git.init({ cwd: branchCwd }); + setThreadError(null); + await loadBranches(); + } catch (error) { + setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."); + } finally { + setIsInitializingRepo(false); + } + }; + + const selectBranch = async (branch: GitBranch) => { + if (!api || !activeThread || !branchCwd) return; + + // For new worktree mode, selecting a branch picks the base branch. + if (envMode === "worktree" && !envLocked && !activeThread.worktreePath) { + setThreadError(null); + setThreadBranch(branch.name, null); + setIsBranchMenuOpen(false); + return; + } + + try { + await api.git.checkout({ + cwd: branchCwd, + branch: branch.name, + }); + setThreadError(null); + setThreadBranch(branch.name, activeThread.worktreePath); + setIsBranchMenuOpen(false); + await loadBranches(); + } catch (error) { + setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); + } + }; + + const createBranch = async () => { + const name = newBranchName.trim(); + if (!api || !activeThread || !branchCwd || !name) return; + try { + await api.git.createBranch({ cwd: branchCwd, branch: name }); + await api.git.checkout({ cwd: branchCwd, branch: name }); + setThreadError(null); + setThreadBranch(name, activeThread.worktreePath); + setNewBranchName(""); + setIsCreatingBranch(false); + setIsBranchMenuOpen(false); + await loadBranches(); + } catch (error) { + setThreadError(error instanceof Error ? error.message : "Failed to create branch."); + } + }; + + if (!activeThread || !activeProject) return null; + + return ( +
+
+ {envLocked ? ( + + {activeThread.worktreePath ? "Worktree" : "Local"} + + ) : ( + + )} +
+ + {!isRepo ? ( + + ) : ( +
+ + {isBranchMenuOpen && ( +
+

+ Branch +

+
+ {branches.map((branch) => { + const isSelected = branch.name === activeThread.branch; + return ( + + ); + })} +
+ {envMode === "local" && ( + <> +
+ {isCreatingBranch ? ( +
{ + event.preventDefault(); + void createBranch(); + }} + > + setNewBranchName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsCreatingBranch(false); + setNewBranchName(""); + } + }} + // biome-ignore lint/a11y/noAutofocus: branch name input should focus when shown + autoFocus + /> + +
+ ) : ( + + )} + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55acbbcc7fd8eba4131da5a60a5526f9c0d8b2cc..fe530e1e3f0b112b682f2ef40b88e637330c851f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -33,6 +33,7 @@ import { readNativeApi, } from "../session-logic"; import { useStore } from "../store"; +import BranchToolbar from "./BranchToolbar"; import ChatMarkdown from "./ChatMarkdown"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -148,6 +149,7 @@ export default function ChatView() { }); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); + const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState( [], @@ -265,6 +267,11 @@ export default function ChatView() { approvalPolicy: "on-request", sandboxMode: "workspace-write", } as const); + const envLocked = Boolean( + activeThread && + (activeThread.messages.length > 0 || + (activeThread.session !== null && activeThread.session.status !== "closed")), + ); const handleRuntimeModeChange = async ( mode: "approval-required" | "full-access", @@ -309,6 +316,11 @@ export default function ChatView() { setExpandedWorkGroups({}); }, [activeThread?.id]); + useEffect(() => { + if (!activeThread) return; + setEnvMode(activeThread.worktreePath ? "worktree" : "local"); + }, [activeThread]); + // Auto-resize textarea useEffect(() => { const ta = textareaRef.current; @@ -450,6 +462,39 @@ export default function ChatView() { if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); if (!trimmed) return; + if (!activeProject) return; + + // On first message: lock in branch + create worktree if needed. + let sessionCwd: string | undefined; + if ( + activeThread.messages.length === 0 && + activeThread.branch && + envMode === "worktree" && + !activeThread.worktreePath + ) { + try { + const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; + const result = await api.git.createWorktree({ + cwd: activeProject.cwd, + branch: activeThread.branch, + newBranch, + }); + sessionCwd = result.worktree.path; + dispatch({ + type: "SET_THREAD_BRANCH", + threadId: activeThread.id, + branch: result.worktree.branch, + worktreePath: result.worktree.path, + }); + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error: err instanceof Error ? err.message : "Failed to create worktree", + }); + return; + } + } // Auto-title from first message if (activeThread.messages.length === 0) { @@ -475,7 +520,7 @@ export default function ChatView() { const previousMessages = activeThread.messages; setPrompt(""); - const sessionInfo = await ensureSession(activeThread.worktreePath ?? undefined); + const sessionInfo = await ensureSession(sessionCwd); if (!sessionInfo) return; setIsSending(true); @@ -910,6 +955,12 @@ export default function ChatView() { )}
+ + {/* Input bar */}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 82568b4f9fbd623b3800feabee7094b5559fc9ea..d3789c66671daa536c8bae3c41aa299ce0bb78e0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -57,6 +57,8 @@ export default function Sidebar() { events: [], error: null, createdAt: new Date().toISOString(), + branch: null, + worktreePath: null, }, }); }, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index c10bfe8e7951423616b87004d28cd74d6a06fa74..4b3e03678126c614a0bb642b70318e62d673ca05 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -39,6 +39,8 @@ function makeThread(overrides: Partial = {}): Thread { events: [], error: null, createdAt: "2026-02-09T00:00:00.000Z", + branch: null, + worktreePath: null, ...overrides, }; } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index f9b075b89e015391082b5c7e8139591613f8aa54..2ee930fbb95d034fde40a3bee5a1a762f6600d30 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -33,6 +33,12 @@ type Action = | { type: "SET_ERROR"; threadId: string; error: string | null } | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | { type: "SET_THREAD_MODEL"; threadId: string; model: string } + | { + type: "SET_THREAD_BRANCH"; + threadId: string; + branch: string | null; + worktreePath: string | null; + } | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; // ── State ──────────────────────────────────────────────────────────── @@ -369,6 +375,16 @@ export function reducer(state: AppState, action: Action): AppState { })), }; + case "SET_THREAD_BRANCH": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (t) => ({ + ...t, + branch: action.branch, + worktreePath: action.worktreePath, + })), + }; + case "SET_RUNTIME_MODE": return { ...state, diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 1ed5d5d2a1f6c47712e22ef4e4f8f22083232405..7e8b3ad02814035da230396e01a24b5c731c6240 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -95,6 +95,16 @@ export function createWsNativeApi(): NativeApi { openInEditor: (cwd, editor) => transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), }, + git: { + listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), + createWorktree: (input) => + transport.request(WS_METHODS.gitCreateWorktree, input), + removeWorktree: (input) => + transport.request(WS_METHODS.gitRemoveWorktree, input), + createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), + checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), + init: (input) => transport.request(WS_METHODS.gitInit, input), + }, }; instance = { api, transport }; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 3264ca0ebee8b18a57fd1eb469c7ea832d3e97df..641227500d4905ee62cf986a3209dd453743d70b 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -19,6 +19,14 @@ export const WS_METHODS = { // Shell methods shellOpenInEditor: "shell.openInEditor", + // Git methods + gitListBranches: "git.listBranches", + gitCreateWorktree: "git.createWorktree", + gitRemoveWorktree: "git.removeWorktree", + gitCreateBranch: "git.createBranch", + gitCheckout: "git.checkout", + gitInit: "git.init", + // Server meta serverGetConfig: "server.getConfig", } as const; -- 2.54.0