From 1869cb319d55133900d4f173fcdd2c61a4cf7bd7 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sat, 10 Jan 2026 11:01:33 +0800 Subject: [PATCH 001/237] feat: add lightweight source control panel to right sidebar - Add Git API backend with routes for status, branches, checkout, diff, stage, unstage, discard, commit - Create reactive Git store for state management - Implement SourceControlPanel component with branch selector, file lists, and commit interface - Integrate panel into right sidebar accordion - Fix accordion collapse/expand by removing force-reset createEffect --- packages/opencode-config/package.json | 2 +- packages/server/src/api-types.ts | 70 +++ packages/server/src/server/http-server.ts | 4 +- packages/server/src/server/routes/git.ts | 461 ++++++++++++++++++ .../components/instance/instance-shell2.tsx | 14 +- .../source-control/source-control-panel.tsx | 406 +++++++++++++++ packages/ui/src/lib/api-client.ts | 54 ++ packages/ui/src/stores/git.ts | 189 +++++++ 8 files changed, 1193 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/server/routes/git.ts create mode 100644 packages/ui/src/components/source-control/source-control-panel.tsx create mode 100644 packages/ui/src/stores/git.ts diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index fa8aa1ea..2fb7392f 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.8" + "@opencode-ai/plugin": "1.1.3" } } diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index b889cac3..03df1776 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -246,6 +246,76 @@ export interface BackgroundProcessOutputResponse { sizeBytes: number } +// Git Source Control Types +export type GitFileStatus = "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" + +export interface GitFileChange { + /** Relative path to the file from workspace root */ + path: string + /** Status of the file */ + status: GitFileStatus + /** Whether the file is staged */ + staged: boolean + /** Original path for renamed files */ + originalPath?: string +} + +export interface GitBranch { + /** Branch name */ + name: string + /** Whether this is the current branch */ + current: boolean + /** Whether this is a remote branch */ + remote: boolean + /** Tracking branch for local branches */ + upstream?: string +} + +export interface GitStatus { + /** Current branch name */ + branch: string + /** List of file changes */ + changes: GitFileChange[] + /** Whether the repo has any commits */ + hasCommits: boolean + /** Number of commits ahead of upstream */ + ahead: number + /** Number of commits behind upstream */ + behind: number +} + +export interface GitBranchListResponse { + branches: GitBranch[] + current: string +} + +export interface GitDiffResponse { + /** File path */ + path: string + /** Unified diff content */ + diff: string + /** Whether the file is binary */ + isBinary: boolean +} + +export interface GitCommitRequest { + message: string +} + +export interface GitCommitResponse { + hash: string + message: string +} + +export interface GitCheckoutRequest { + branch: string + create?: boolean +} + +export interface GitStageRequest { + paths: string[] +} + export type { Preferences, ModelPreference, diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 26542790..8ce46a82 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -20,6 +20,7 @@ import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" +import { registerGitRoutes } from "./routes/git" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" @@ -66,7 +67,7 @@ export function createHttpServer(deps: HttpServerDeps) { } app.addHook("onRequest", (request, _reply, done) => { - ;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = { + ; (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = { start: process.hrtime.bigint(), } done() @@ -121,6 +122,7 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) + registerGitRoutes(app, { workspaceManager: deps.workspaceManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/git.ts b/packages/server/src/server/routes/git.ts new file mode 100644 index 00000000..fa3e51be --- /dev/null +++ b/packages/server/src/server/routes/git.ts @@ -0,0 +1,461 @@ +import type { FastifyInstance } from "fastify" +import { exec } from "child_process" +import { promisify } from "util" +import path from "path" +import type { WorkspaceManager } from "../../workspaces/manager" +import type { + GitStatus, + GitFileChange, + GitFileStatus, + GitBranch, + GitBranchListResponse, + GitDiffResponse, + GitCommitRequest, + GitCommitResponse, + GitCheckoutRequest, + GitStageRequest, +} from "../../api-types" + +const execAsync = promisify(exec) + +interface GitRoutesDeps { + workspaceManager: WorkspaceManager +} + +async function runGitCommand(cwd: string, args: string): Promise { + try { + const { stdout } = await execAsync(`git ${args}`, { + cwd, + maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large diffs + }) + return stdout.trim() + } catch (error) { + const execError = error as { stderr?: string; message?: string } + throw new Error(execError.stderr || execError.message || "Git command failed") + } +} + +async function isGitRepository(cwd: string): Promise { + try { + await runGitCommand(cwd, "rev-parse --git-dir") + return true + } catch { + return false + } +} + +function parseStatusLine(line: string): GitFileChange | null { + if (line.length < 3) return null + + const indexStatus = line[0] + const worktreeStatus = line[1] + const filePath = line.slice(3) + + // Handle renames (format: "R old -> new") + let actualPath = filePath + let originalPath: string | undefined + if (filePath.includes(" -> ")) { + const parts = filePath.split(" -> ") + originalPath = parts[0] + actualPath = parts[1] + } + + // Determine status and staging state + let status: GitFileStatus + let staged = false + + if (indexStatus === "?" && worktreeStatus === "?") { + status = "untracked" + } else if (indexStatus === "!" && worktreeStatus === "!") { + status = "ignored" + } else if (indexStatus !== " " && indexStatus !== "?") { + // File has staged changes + staged = true + switch (indexStatus) { + case "M": + status = "modified" + break + case "A": + status = "added" + break + case "D": + status = "deleted" + break + case "R": + status = "renamed" + break + case "C": + status = "copied" + break + default: + status = "modified" + } + } else { + // File has unstaged changes + switch (worktreeStatus) { + case "M": + status = "modified" + break + case "D": + status = "deleted" + break + default: + status = "modified" + } + } + + return { + path: actualPath, + status, + staged, + originalPath, + } +} + +async function getGitStatus(cwd: string): Promise { + // Check if there are any commits + let hasCommits = true + try { + await runGitCommand(cwd, "rev-parse HEAD") + } catch { + hasCommits = false + } + + // Get current branch + let branch = "HEAD" + try { + branch = await runGitCommand(cwd, "symbolic-ref --short HEAD") + } catch { + // Detached HEAD state + try { + branch = (await runGitCommand(cwd, "rev-parse --short HEAD")).slice(0, 7) + } catch { + branch = "HEAD" + } + } + + // Get ahead/behind counts + let ahead = 0 + let behind = 0 + try { + const tracking = await runGitCommand(cwd, "rev-parse --abbrev-ref @{upstream}") + if (tracking) { + const counts = await runGitCommand(cwd, `rev-list --left-right --count HEAD...@{upstream}`) + const [aheadStr, behindStr] = counts.split("\t") + ahead = parseInt(aheadStr, 10) || 0 + behind = parseInt(behindStr, 10) || 0 + } + } catch { + // No upstream tracking + } + + // Get file status + const statusOutput = await runGitCommand(cwd, "status --porcelain=v1") + const changes: GitFileChange[] = [] + + if (statusOutput) { + for (const line of statusOutput.split("\n")) { + const change = parseStatusLine(line) + if (change) { + changes.push(change) + } + } + } + + return { + branch, + changes, + hasCommits, + ahead, + behind, + } +} + +async function getBranches(cwd: string): Promise { + const branches: GitBranch[] = [] + let current = "" + + // Get local branches + try { + const localOutput = await runGitCommand(cwd, "branch --format='%(refname:short)|%(upstream:short)|%(HEAD)'") + for (const line of localOutput.split("\n")) { + if (!line.trim()) continue + const cleanLine = line.replace(/'/g, "") + const [name, upstream, head] = cleanLine.split("|") + const isCurrent = head === "*" + if (isCurrent) current = name + branches.push({ + name, + current: isCurrent, + remote: false, + upstream: upstream || undefined, + }) + } + } catch { + // No branches yet + } + + // Get remote branches + try { + const remoteOutput = await runGitCommand(cwd, "branch -r --format='%(refname:short)'") + for (const line of remoteOutput.split("\n")) { + if (!line.trim()) continue + const name = line.replace(/'/g, "").trim() + // Skip HEAD references + if (name.includes("/HEAD")) continue + branches.push({ + name, + current: false, + remote: true, + }) + } + } catch { + // No remote branches + } + + return { branches, current } +} + +export function registerGitRoutes(app: FastifyInstance, deps: GitRoutesDeps) { + const { workspaceManager } = deps + + // Helper to get workspace path + const getWorkspacePath = (id: string): string | null => { + const workspace = workspaceManager.get(id) + return workspace?.path ?? null + } + + // GET /api/workspaces/:id/git/status + app.get<{ Params: { id: string } }>("/api/workspaces/:id/git/status", async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + try { + const status = await getGitStatus(workspacePath) + return status + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get git status" + return reply.status(500).send({ error: message }) + } + }) + + // GET /api/workspaces/:id/git/branches + app.get<{ Params: { id: string } }>("/api/workspaces/:id/git/branches", async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + try { + const branches = await getBranches(workspacePath) + return branches + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get branches" + return reply.status(500).send({ error: message }) + } + }) + + // POST /api/workspaces/:id/git/checkout + app.post<{ Params: { id: string }; Body: GitCheckoutRequest }>( + "/api/workspaces/:id/git/checkout", + async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + const { branch, create } = request.body + if (!branch) { + return reply.status(400).send({ error: "Branch name is required" }) + } + + try { + const args = create ? `checkout -b ${branch}` : `checkout ${branch}` + await runGitCommand(workspacePath, args) + return { success: true, branch } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to checkout branch" + return reply.status(500).send({ error: message }) + } + }, + ) + + // GET /api/workspaces/:id/git/diff + app.get<{ Params: { id: string }; Querystring: { path?: string; staged?: string } }>( + "/api/workspaces/:id/git/diff", + async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + const filePath = request.query.path + const staged = request.query.staged === "true" + + try { + let args = staged ? "diff --cached" : "diff" + if (filePath) { + args += ` -- ${filePath}` + } + + const diff = await runGitCommand(workspacePath, args) + + // Check if file is binary + const isBinary = diff.includes("Binary files") || diff.includes("GIT binary patch") + + const response: GitDiffResponse = { + path: filePath || "", + diff, + isBinary, + } + return response + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get diff" + return reply.status(500).send({ error: message }) + } + }, + ) + + // POST /api/workspaces/:id/git/stage + app.post<{ Params: { id: string }; Body: GitStageRequest }>( + "/api/workspaces/:id/git/stage", + async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + const { paths } = request.body + if (!paths || paths.length === 0) { + return reply.status(400).send({ error: "Paths are required" }) + } + + try { + const quotedPaths = paths.map((p) => `"${p}"`).join(" ") + await runGitCommand(workspacePath, `add ${quotedPaths}`) + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to stage files" + return reply.status(500).send({ error: message }) + } + }, + ) + + // POST /api/workspaces/:id/git/unstage + app.post<{ Params: { id: string }; Body: GitStageRequest }>( + "/api/workspaces/:id/git/unstage", + async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + const { paths } = request.body + if (!paths || paths.length === 0) { + return reply.status(400).send({ error: "Paths are required" }) + } + + try { + const quotedPaths = paths.map((p) => `"${p}"`).join(" ") + await runGitCommand(workspacePath, `reset HEAD -- ${quotedPaths}`) + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to unstage files" + return reply.status(500).send({ error: message }) + } + }, + ) + + // POST /api/workspaces/:id/git/discard + app.post<{ Params: { id: string }; Body: GitStageRequest }>( + "/api/workspaces/:id/git/discard", + async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + const { paths } = request.body + if (!paths || paths.length === 0) { + return reply.status(400).send({ error: "Paths are required" }) + } + + try { + const quotedPaths = paths.map((p) => `"${p}"`).join(" ") + await runGitCommand(workspacePath, `checkout -- ${quotedPaths}`) + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to discard changes" + return reply.status(500).send({ error: message }) + } + }, + ) + + // POST /api/workspaces/:id/git/commit + app.post<{ Params: { id: string }; Body: GitCommitRequest }>( + "/api/workspaces/:id/git/commit", + async (request, reply) => { + const workspacePath = getWorkspacePath(request.params.id) + if (!workspacePath) { + return reply.status(404).send({ error: "Workspace not found" }) + } + + if (!(await isGitRepository(workspacePath))) { + return reply.status(400).send({ error: "Not a git repository" }) + } + + const { message } = request.body + if (!message || !message.trim()) { + return reply.status(400).send({ error: "Commit message is required" }) + } + + try { + // Escape quotes in message + const escapedMessage = message.replace(/"/g, '\\"') + await runGitCommand(workspacePath, `commit -m "${escapedMessage}"`) + + // Get the commit hash + const hash = await runGitCommand(workspacePath, "rev-parse --short HEAD") + + const response: GitCommitResponse = { + hash, + message: message.trim(), + } + return response + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to commit" + return reply.status(500).send({ error: message }) + } + }, + ) +} diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 74f6362f..0909ed35 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -66,6 +66,7 @@ import { getLogger } from "../../lib/logger" import { serverApi } from "../../lib/api-client" import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" +import SourceControlPanel from "../source-control/source-control-panel" import { SESSION_SIDEBAR_EVENT, type SessionSidebarRequestAction, @@ -139,6 +140,7 @@ const InstanceShell2: Component = (props) => { const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ + "source-control", "plan", "background-processes", "mcp", @@ -996,6 +998,11 @@ const InstanceShell2: Component = (props) => { } const sections = [ + { + id: "source-control", + label: "Source Control", + render: () => , + }, { id: "plan", label: "Plan", @@ -1044,11 +1051,8 @@ const InstanceShell2: Component = (props) => { }, ] - createEffect(() => { - const currentExpanded = new Set(rightPanelExpandedItems()) - if (sections.every((section) => currentExpanded.has(section.id))) return - setRightPanelExpandedItems(sections.map((section) => section.id)) - }) + // Accordion state is managed by user interaction via handleAccordionChange + // No need to force all sections to be expanded const handleAccordionChange = (values: string[]) => { setRightPanelExpandedItems(values) diff --git a/packages/ui/src/components/source-control/source-control-panel.tsx b/packages/ui/src/components/source-control/source-control-panel.tsx new file mode 100644 index 00000000..4940e27a --- /dev/null +++ b/packages/ui/src/components/source-control/source-control-panel.tsx @@ -0,0 +1,406 @@ +import { Component, Show, For, createSignal, createEffect, onMount } from "solid-js" +import { ChevronDown, RefreshCw, GitBranch, Plus, Minus, Undo2, Check } from "lucide-solid" +import type { GitFileChange } from "../../../../server/src/api-types" +import { + useGitStore, + fetchGitBranches, + stageFiles, + unstageFiles, + discardChanges, + commitChanges, + checkoutBranch, + refreshGit, +} from "../../stores/git" +import { serverApi } from "../../lib/api-client" + +interface SourceControlPanelProps { + workspaceId: string +} + +const SourceControlPanel: Component = (props) => { + const git = useGitStore(props.workspaceId) + const [commitMessage, setCommitMessage] = createSignal("") + const [expandedSections, setExpandedSections] = createSignal(["staged", "changes", "untracked"]) + const [showDiff, setShowDiff] = createSignal(false) + const [diffContent, setDiffContent] = createSignal("") + const [diffPath, setDiffPath] = createSignal("") + const [showBranchPicker, setShowBranchPicker] = createSignal(false) + + onMount(() => { + refreshGit(props.workspaceId) + }) + + createEffect(() => { + // Refresh when workspace changes + const id = props.workspaceId + if (id) { + refreshGit(id) + } + }) + + const handleRefresh = () => { + refreshGit(props.workspaceId) + } + + const handleStage = async (path: string) => { + await stageFiles(props.workspaceId, [path]) + } + + const handleUnstage = async (path: string) => { + await unstageFiles(props.workspaceId, [path]) + } + + const handleDiscard = async (path: string) => { + if (confirm(`Discard changes to ${path}?`)) { + await discardChanges(props.workspaceId, [path]) + } + } + + const handleStageAll = async () => { + const paths = [...git.unstagedChanges(), ...git.untrackedChanges()].map((c) => c.path) + if (paths.length > 0) { + await stageFiles(props.workspaceId, paths) + } + } + + const handleUnstageAll = async () => { + const paths = git.stagedChanges().map((c) => c.path) + if (paths.length > 0) { + await unstageFiles(props.workspaceId, paths) + } + } + + const handleCommit = async () => { + const message = commitMessage().trim() + if (!message) return + const success = await commitChanges(props.workspaceId, message) + if (success) { + setCommitMessage("") + } + } + + const handleViewDiff = async (file: GitFileChange) => { + try { + const response = await serverApi.fetchGitDiff(props.workspaceId, file.path, file.staged) + setDiffPath(file.path) + setDiffContent(response.diff) + setShowDiff(true) + } catch (error) { + console.error("Failed to fetch diff", error) + } + } + + const handleBranchSelect = async (branch: string) => { + setShowBranchPicker(false) + await checkoutBranch(props.workspaceId, branch) + } + + const getStatusIcon = (status: GitFileChange["status"]) => { + switch (status) { + case "added": + return "A" + case "modified": + return "M" + case "deleted": + return "D" + case "renamed": + return "R" + case "untracked": + return "U" + default: + return "?" + } + } + + const getStatusColor = (status: GitFileChange["status"]) => { + switch (status) { + case "added": + return "text-green-500" + case "modified": + return "text-yellow-500" + case "deleted": + return "text-red-500" + case "renamed": + return "text-blue-500" + case "untracked": + return "text-gray-400" + default: + return "text-secondary" + } + } + + const FileChangeItem: Component<{ + file: GitFileChange + showStage?: boolean + showUnstage?: boolean + showDiscard?: boolean + }> = (itemProps) => ( +
+ + {getStatusIcon(itemProps.file.status)} + + + +
+ ) + + return ( +
+ +

Not a git repository.

+
+ + + {/* Branch selector */} +
+
+ + +
+ !b.remote)}> + {(branch) => ( + + )} + +
+
+
+ +
+ + {/* Commit input */} +
+