diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index b2a1e577..f46796b4 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -81,6 +81,55 @@ export interface WorktreeMap { parentSessionWorktreeSlug: Record } +export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged" + +export interface WorktreeGitStatusEntry { + path: string + originalPath?: string | null + stagedStatus: GitChangeKind | null + stagedAdditions: number + stagedDeletions: number + unstagedStatus: GitChangeKind | null + unstagedAdditions: number + unstagedDeletions: number +} + +export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[] + +export type WorktreeGitDiffScope = "staged" | "unstaged" + +export interface WorktreeGitPathsRequest { + paths: string[] +} + +export interface WorktreeGitMutationResponse { + ok: true +} + +export interface WorktreeGitCommitRequest { + message: string +} + +export interface WorktreeGitCommitResponse { + ok: true + commitSha?: string +} + +export interface WorktreeGitDiffResponse { + path: string + originalPath?: string | null + scope: WorktreeGitDiffScope + before: string + after: string + isBinary?: boolean +} + +export interface WorktreeGitDiffRequest { + path: string + originalPath?: string | null + scope: WorktreeGitDiffScope +} + export type LogLevel = "debug" | "info" | "warn" | "error" export interface WorkspaceLogEntry { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index ee5355c5..9d4e0e1a 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -10,6 +10,7 @@ import { fetch } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" +import { resolveWorktreeDirectory } from "../workspaces/worktree-directory" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -760,52 +761,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) { return trimmed.length === 0 ? "/" : `/${trimmed}` } -type WorktreeCacheEntry = { - expiresAt: number - repoRoot: string - worktrees: Array<{ slug: string; directory: string }> -} - -const WORKTREE_CACHE_TTL_MS = 2000 -const worktreeCache = new Map() - -async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) { - const cached = worktreeCache.get(params.workspaceId) - const now = Date.now() - if (cached && cached.expiresAt > now) { - return cached - } - - const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) - const entry: WorktreeCacheEntry = { - expiresAt: now + WORKTREE_CACHE_TTL_MS, - repoRoot, - worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })), - } - worktreeCache.set(params.workspaceId, entry) - return entry -} - -async function resolveWorktreeDirectory(params: { - workspaceId: string - workspacePath: string - worktreeSlug: string - logger: Logger -}): Promise { - const { worktreeSlug } = params - const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger }) - const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug) - if (match) { - return match.directory - } - - // If the slug is new (e.g., created moments ago), refresh once. - worktreeCache.delete(params.workspaceId) - const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger }) - return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null -} - function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) { if (!uiDir) { app.log.warn("UI static directory not provided; API endpoints only") diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index de2afd22..9f2a68a2 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -1,6 +1,10 @@ import { FastifyInstance, FastifyReply } from "fastify" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" +import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status" +import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations" +import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees" +import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory" interface RouteDeps { workspaceManager: WorkspaceManager @@ -23,6 +27,20 @@ const WorkspaceFileContentBodySchema = z.object({ contents: z.string(), }) +const WorktreeGitDiffQuerySchema = z.object({ + path: z.string().trim().min(1, "Path is required"), + originalPath: z.string().trim().optional(), + scope: z.enum(["staged", "unstaged"]), +}) + +const WorktreeGitPathsBodySchema = z.object({ + paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"), +}) + +const WorktreeGitCommitBodySchema = z.object({ + message: z.string().trim().min(1, "Commit message is required"), +}) + const WorkspaceFileSearchQuerySchema = z.object({ q: z.string().trim().min(1, "Query is required"), limit: z.coerce.number().int().positive().max(200).optional(), @@ -118,10 +136,138 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { return handleWorkspaceError(error, reply) } }) + + app.get<{ + Params: { id: string; slug: string } + }>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => { + try { + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log }) + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.get<{ + Params: { id: string; slug: string } + Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" } + }>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => { + try { + const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + return await getWorktreeGitDiff({ + workspaceFolder: directory, + path: query.path, + originalPath: query.originalPath, + scope: query.scope, + }) + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.post<{ + Params: { id: string; slug: string } + Body: { paths: string[] } + }>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => { + try { + const body = WorktreeGitPathsBodySchema.parse(request.body ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + return { ok: true as const } + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.post<{ + Params: { id: string; slug: string } + Body: { paths: string[] } + }>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => { + try { + const body = WorktreeGitPathsBodySchema.parse(request.body ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths }) + return { ok: true as const } + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) + + app.post<{ + Params: { id: string; slug: string } + Body: { message: string } + }>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => { + try { + const body = WorktreeGitCommitBodySchema.parse(request.body ?? {}) + const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply) + if (!directory) return + + const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message }) + return { ok: true as const, ...result } + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) +} + +async function resolveGitWorktreeDirectory( + workspaceManager: WorkspaceManager, + workspaceId: string, + worktreeSlug: string, + logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void }, + reply: FastifyReply, +): Promise { + const workspace = workspaceManager.get(workspaceId) + if (!workspace) { + reply.code(404) + reply.send({ error: "Workspace not found" }) + return null + } + + const gitAvailable = await isGitAvailable(workspace.path) + if (!gitAvailable) { + reply.code(503) + reply.send({ error: "Git is not installed or not available in PATH" }) + return null + } + + const { isGitRepo } = await resolveRepoRoot(workspace.path, logger) + if (!isGitRepo) { + reply.code(400) + reply.send({ error: "Workspace is not a Git repository" }) + return null + } + + const directory = await resolveWorktreeDirectory({ + workspaceId: workspace.id, + workspacePath: workspace.path, + worktreeSlug, + logger, + }) + if (!directory) { + reply.code(404) + reply.send({ error: "Worktree not found" }) + return null + } + + return directory } function handleWorkspaceError(error: unknown, reply: FastifyReply) { + if (isGitMutationError(error)) { + reply.code(error.statusCode) + return { error: error.message } + } if (error instanceof Error && error.message === "Workspace not found") { reply.code(404) return { error: "Workspace not found" } diff --git a/packages/server/src/workspaces/git-mutations.ts b/packages/server/src/workspaces/git-mutations.ts new file mode 100644 index 00000000..32c1ed53 --- /dev/null +++ b/packages/server/src/workspaces/git-mutations.ts @@ -0,0 +1,121 @@ +import { spawn } from "child_process" +import path from "path" + +type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } + +class GitMutationError extends Error { + statusCode: number + + constructor(message: string, statusCode = 400) { + super(message) + this.name = "GitMutationError" + this.statusCode = statusCode + } +} + +function runGit(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString() + }) + child.once("error", (error) => { + resolve({ ok: false, error, stdout, stderr }) + }) + child.once("close", (code) => { + if (code === 0) { + resolve({ ok: true, stdout }) + } else { + const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`) + resolve({ ok: false, error, stdout, stderr }) + } + }) + }) +} + +export function normalizeGitWorktreeRelativePath(input: string): string { + const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "") + if (!normalized) { + throw new GitMutationError("Path is required", 400) + } + if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) { + throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400) + } + if (normalized === "." || normalized === "..") { + throw new GitMutationError(`Invalid path: ${input}`, 400) + } + if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) { + throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400) + } + return normalized +} + +function normalizeGitMutationPaths(paths: string[]): string[] { + const deduped = new Set() + for (const rawPath of paths) { + deduped.add(normalizeGitWorktreeRelativePath(rawPath)) + } + const normalized = Array.from(deduped) + if (normalized.length === 0) { + throw new GitMutationError("At least one path is required", 400) + } + return normalized +} + +async function ensureGitCommandSucceeded(resultPromise: Promise, fallbackMessage: string): Promise { + const result = await resultPromise + if (!result.ok) { + const message = result.stderr?.trim() || result.error.message || fallbackMessage + throw new GitMutationError(message, 409) + } + return result.stdout +} + +export function isGitMutationError(error: unknown): error is GitMutationError { + return error instanceof GitMutationError +} + +export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise { + const paths = normalizeGitMutationPaths(params.paths) + await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files") +} + +export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise { + const paths = normalizeGitMutationPaths(params.paths) + const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder) + if (headResult.ok) { + await ensureGitCommandSucceeded( + runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder), + "Failed to unstage files", + ) + return + } + + await ensureGitCommandSucceeded( + runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder), + "Failed to unstage files", + ) +} + +export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> { + const message = params.message.trim() + if (!message) { + throw new GitMutationError("Commit message is required", 400) + } + + await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit") + + const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder) + if (!shaResult.ok) { + return {} + } + + const commitSha = shaResult.stdout.trim() + return commitSha ? { commitSha } : {} +} diff --git a/packages/server/src/workspaces/git-status.ts b/packages/server/src/workspaces/git-status.ts new file mode 100644 index 00000000..57d7cfb2 --- /dev/null +++ b/packages/server/src/workspaces/git-status.ts @@ -0,0 +1,385 @@ +import { spawn } from "child_process" +import { readFile } from "fs/promises" +import path from "path" + +import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types" +import type { LogLike } from "./git-worktrees" +import { normalizeGitWorktreeRelativePath } from "./git-mutations" + +type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } +type GitSuccessResult = Extract + +async function readFileAsDiffText(filePath: string): Promise { + return readFile(filePath, "utf-8") +} + +async function readGitBlobAsDiffText(resultPromise: Promise, missingOk = false): Promise { + const result = await resultPromise + if (!result.ok) { + return decodeGitShowResult(result, missingOk) + } + return result.stdout +} + +function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise { + return new Promise((resolve) => { + const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString() + }) + child.once("error", (error) => { + resolve({ ok: false, error, stdout, stderr }) + }) + child.once("close", (code) => { + if (acceptedExitCodes.includes(code ?? 0)) { + resolve({ ok: true, stdout }) + } else { + const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`) + resolve({ ok: false, error, stdout, stderr }) + } + }) + }) +} + +function ensureEntry(map: Map, path: string): WorktreeGitStatusEntry { + const existing = map.get(path) + if (existing) return existing + const next: WorktreeGitStatusEntry = { + path, + originalPath: null, + stagedStatus: null, + stagedAdditions: 0, + stagedDeletions: 0, + unstagedStatus: null, + unstagedAdditions: 0, + unstagedDeletions: 0, + } + map.set(path, next) + return next +} + +function normalizeGitStatusPath(value: string): string { + return value.trim().replace(/\\+/g, "/") +} + +function parseGitChangeKind(code: string): GitChangeKind | null { + const normalized = code.trim().toUpperCase() + if (!normalized) return null + if (normalized === "A") return "added" + if (normalized === "M") return "modified" + if (normalized === "D") return "deleted" + if (normalized.startsWith("R")) return "renamed" + if (normalized.startsWith("C")) return "copied" + if (normalized === "U") return "unmerged" + return null +} + +function applyNameStatusOutput( + map: Map, + output: string, + target: "stagedStatus" | "unstagedStatus", +) { + const tokens = output.split("\0") + let index = 0 + + while (index < tokens.length) { + const record = tokens[index++] ?? "" + if (!record) continue + + const parts = record.split("\t") + const statusCode = parseGitChangeKind(parts[0] ?? "") + if (!statusCode) continue + + const inlinePath = parts.slice(1).join("\t") + const firstPath = inlinePath || tokens[index++] || "" + const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : "" + const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath + const normalizedPath = normalizeGitStatusPath(path) + if (!normalizedPath) continue + const entry = ensureEntry(map, normalizedPath) + entry[target] = statusCode + if (statusCode === "renamed" || statusCode === "copied") { + const originalPath = normalizeGitStatusPath(firstPath) + entry.originalPath = originalPath || entry.originalPath || null + } + } +} + +function applyUntrackedOutput(map: Map, output: string) { + for (const rawLine of output.split(/\r?\n/)) { + const path = normalizeGitStatusPath(rawLine) + if (!path) continue + ensureEntry(map, path).unstagedStatus = "untracked" + } +} + +function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } { + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line) continue + const parts = rawLine.split("\t") + const isBinary = parts[0] === "-" || parts[1] === "-" + return { + additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0, + deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0, + isBinary, + found: true, + } + } + + return { additions: 0, deletions: 0, isBinary: false, found: false } +} + +async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> { + const absolutePath = path.join(workspaceFolder, relativePath) + const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1]) + if (!result.ok) { + throw result.error + } + + const parsed = parseSingleNumstat(result.stdout) + return { additions: parsed.additions, deletions: parsed.deletions } +} + +async function applyUntrackedFileStats(map: Map, workspaceFolder: string) { + const pending = Array.from(map.values()) + .filter((entry) => entry.unstagedStatus === "untracked") + .map(async (entry) => { + try { + const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path) + entry.unstagedAdditions = stats.additions + entry.unstagedDeletions = stats.deletions + } catch { + entry.unstagedAdditions = 0 + entry.unstagedDeletions = 0 + } + }) + await Promise.all(pending) +} + +function applyNumstatOutput( + map: Map, + output: string, + target: "staged" | "unstaged", +) { + const tokens = output.split("\0") + let index = 0 + + while (index < tokens.length) { + const record = tokens[index++] ?? "" + if (!record) continue + + const parts = record.split("\t") + if (parts.length < 3) continue + + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10) + const inlinePath = parts.slice(2).join("\t") + const isRenameLike = inlinePath === "" + const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null + const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath) + if (!normalizedPath) continue + + const entry = ensureEntry(map, normalizedPath) + if (originalPath) { + entry.originalPath = originalPath + } + + if (target === "staged") { + entry.stagedAdditions = Number.isFinite(additions) ? additions : 0 + entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0 + } else { + entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0 + entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0 + } + } +} + +export async function getWorktreeGitStatus(params: { + workspaceFolder: string + logger?: LogLike +}): Promise { + const { workspaceFolder, logger } = params + const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([ + runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder), + runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder), + runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder), + runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder), + ]) + + for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) { + if (!result.ok) { + logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree") + throw result.error + } + } + + const stagedOutput = (stagedResult as GitSuccessResult).stdout + const unstagedOutput = (unstagedResult as GitSuccessResult).stdout + const untrackedOutput = (untrackedResult as GitSuccessResult).stdout + const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout + const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout + + const entries = new Map() + applyNameStatusOutput(entries, stagedOutput, "stagedStatus") + applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus") + applyUntrackedOutput(entries, untrackedOutput) + applyNumstatOutput(entries, stagedNumstatOutput, "staged") + applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged") + await applyUntrackedFileStats(entries, workspaceFolder) + + return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path)) +} + +function decodeGitShowResult(result: GitResult, missingOk = false): string { + if (result.ok) return result.stdout + const message = result.stderr?.trim() || result.error.message || "" + if ( + missingOk && + (message.includes("exists on disk, but not in") || + message.includes("Path '") || + message.includes("does not exist") || + message.includes("unknown revision or path not in the working tree")) + ) { + return "" + } + throw result.error +} + +async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise { + return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder) +} + +async function getTrackedDiffMetadata(params: { + workspaceFolder: string + scope: WorktreeGitDiffScope + normalizedPath: string + normalizedOriginalPath: string | null +}): Promise<{ isBinary: boolean; found: boolean }> { + const args = ["diff", "--numstat"] + if (params.scope === "staged") { + args.push("--cached") + } + args.push("--find-renames", "--find-copies", "--") + args.push(params.normalizedPath) + if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) { + args.push(params.normalizedOriginalPath) + } + + const result = await runGit(args, params.workspaceFolder) + if (!result.ok) { + throw result.error + } + + const parsed = parseSingleNumstat(result.stdout) + return { isBinary: parsed.isBinary, found: parsed.found } +} + +async function getUntrackedDiffMetadata(params: { + workspaceFolder: string + normalizedPath: string +}): Promise<{ isBinary: boolean }> { + const absolutePath = path.join(params.workspaceFolder, params.normalizedPath) + const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1]) + if (!result.ok) { + throw result.error + } + + return { isBinary: parseSingleNumstat(result.stdout).isBinary } +} + +async function resolveUnstagedBeforePath(params: { + workspaceFolder: string + normalizedPath: string + normalizedOriginalPath: string | null +}): Promise { + const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath) + if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) { + return currentPathResult + } + return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath) +} + +export async function getWorktreeGitDiff(params: { + workspaceFolder: string + path: string + originalPath?: string | null + scope: WorktreeGitDiffScope +}): Promise { + const normalizedPath = normalizeGitWorktreeRelativePath(params.path) + const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null + + const trackedMetadata = await getTrackedDiffMetadata({ + workspaceFolder: params.workspaceFolder, + scope: params.scope, + normalizedPath, + normalizedOriginalPath, + }) + + const diffMetadata = + params.scope === "unstaged" && !trackedMetadata.found + ? await getUntrackedDiffMetadata({ + workspaceFolder: params.workspaceFolder, + normalizedPath, + }) + : trackedMetadata + + if (diffMetadata.isBinary) { + return { + path: normalizedPath, + originalPath: normalizedOriginalPath, + scope: params.scope, + before: "", + after: "", + isBinary: true, + } + } + + if (params.scope === "staged") { + const [beforeResult, afterResult] = await Promise.all([ + readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true), + readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true), + ]) + + return { + path: normalizedPath, + originalPath: normalizedOriginalPath, + scope: params.scope, + before: beforeResult, + after: afterResult, + isBinary: false, + } + } + + const indexResult = await resolveUnstagedBeforePath({ + workspaceFolder: params.workspaceFolder, + normalizedPath, + normalizedOriginalPath, + }) + + const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true) + let after = beforeResult + + const fsPath = path.join(params.workspaceFolder, normalizedPath) + try { + after = await readFileAsDiffText(fsPath) + } catch { + after = "" + } + + return { + path: normalizedPath, + originalPath: normalizedOriginalPath, + scope: params.scope, + before: beforeResult, + after, + isBinary: false, + } +} diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index f9b5998e..9e53e305 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -10,6 +10,10 @@ export interface LogLike { type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } +function isGitUnavailableResult(result: GitResult): boolean { + return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" +} + function runGit(args: string[], cwd: string): Promise { return new Promise((resolve) => { const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) @@ -38,6 +42,9 @@ function runGit(args: string[], cwd: string): Promise { export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> { const result = await runGit(["rev-parse", "--show-toplevel"], folder) + if (isGitUnavailableResult(result)) { + throw new Error("Git is not installed or not available in PATH") + } if (!result.ok) { logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root") return { repoRoot: folder, isGitRepo: false } @@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise return { repoRoot, isGitRepo: true } } +export async function isGitAvailable(folder: string): Promise { + const result = await runGit(["--version"], folder) + return result.ok || !isGitUnavailableResult(result) +} + function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> { const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = [] const lines = output.split(/\r?\n/) @@ -90,15 +102,22 @@ export async function listWorktrees(params: { logger?: LogLike }): Promise { const { repoRoot, workspaceFolder, logger } = params - const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" } const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) if (!result.ok) { + const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" } logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") return [rootDescriptor] } const records = parseWorktreePorcelain(result.stdout) + const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot)) + const rootDescriptor: WorktreeDescriptor = { + slug: "root", + directory: repoRoot, + kind: "root", + branch: rootRecord?.branch, + } const worktrees: WorktreeDescriptor[] = [rootDescriptor] const seen = new Set(["root"]) diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts new file mode 100644 index 00000000..144aed98 --- /dev/null +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -0,0 +1,99 @@ +import { realpath } from "fs/promises" +import type { LogLike } from "./git-worktrees" +import { listWorktrees, resolveRepoRoot } from "./git-worktrees" + +type WorktreeCacheEntry = { + expiresAt: number + repoRoot: string + worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }> +} + +const WORKTREE_CACHE_TTL_MS = 2000 +const worktreeCache = new Map() + +async function normalizeDirectoryPath(directory: string): Promise { + const trimmed = (directory ?? "").trim() + if (!trimmed) return "" + try { + return await realpath(trimmed) + } catch { + return trimmed + } +} + +async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) { + const cached = worktreeCache.get(params.workspaceId) + const now = Date.now() + if (cached && cached.expiresAt > now) { + return cached + } + + const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) + const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) + const entry: WorktreeCacheEntry = { + expiresAt: now + WORKTREE_CACHE_TTL_MS, + repoRoot, + worktrees: await Promise.all( + worktrees.map(async (wt) => ({ + slug: wt.slug, + directory: wt.directory, + normalizedDirectory: await normalizeDirectoryPath(wt.directory), + })), + ), + } + worktreeCache.set(params.workspaceId, entry) + return entry +} + +export async function resolveWorktreeDirectory(params: { + workspaceId: string + workspacePath: string + worktreeSlug: string + logger?: LogLike +}): Promise { + const cached = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug) + if (match) { + return match.directory + } + + worktreeCache.delete(params.workspaceId) + const refreshed = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null +} + +export async function resolveWorktreeSlugForDirectory(params: { + workspaceId: string + workspacePath: string + directory: string + logger?: LogLike +}): Promise { + const target = await normalizeDirectoryPath(params.directory ?? "") + if (!target) return null + + const cached = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target) + if (match) { + return match.slug + } + + worktreeCache.delete(params.workspaceId) + const refreshed = await getCachedWorktrees({ + workspaceId: params.workspaceId, + workspacePath: params.workspacePath, + logger: params.logger, + }) + return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null +} diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index 6856fb24..3cf2204a 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { loadMonaco } from "../../lib/monaco/setup" import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { inferMonacoLanguageId } from "../../lib/monaco/language" @@ -15,6 +15,8 @@ interface MonacoDiffViewerProps { viewMode?: "split" | "unified" contextMode?: "expanded" | "collapsed" wordWrap?: "on" | "off" + onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void + insertContextLabel?: string } export function MonacoDiffViewer(props: MonacoDiffViewerProps) { @@ -24,6 +26,10 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { let diffEditor: any = null let monaco: any = null const [ready, setReady] = createSignal(false) + const [hoveredLine, setHoveredLine] = createSignal(null) + const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null) + const [widgetHovered, setWidgetHovered] = createSignal(false) + const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null) const resolvedContent = createMemo(() => { if (props.patch !== undefined && props.patch !== null) { @@ -49,6 +55,52 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { diffEditor = null } + const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null + + const getActiveInsertRange = () => { + const selection = selectedRange() + if (selection) return selection + if (widgetHovered() && hoveredLine()) { + return { startLine: hoveredLine() as number, endLine: hoveredLine() as number } + } + const line = hoveredLine() + if (!line) return null + return { startLine: line, endLine: line } + } + + const layoutInsertWidget = () => { + const modifiedEditor = getModifiedEditor() + const container = host + if (!modifiedEditor || !container) return + const activeRange = getActiveInsertRange() + if (!activeRange) { + setWidgetPosition(null) + return + } + + try { + const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null + if (!modifiedDom) { + setWidgetPosition(null) + return + } + + const margin = modifiedDom.querySelector(".margin") + const scrollable = modifiedDom.querySelector(".monaco-scrollable-element.editor-scrollable") + const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0 + const scrollTop = modifiedEditor.getScrollTop?.() ?? 0 + const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18) + const modifiedRect = modifiedDom.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0) + const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2 + + setWidgetPosition({ top: centerTop, left: seamLeft }) + } catch { + setWidgetPosition(null) + } + } + onMount(() => { let cancelled = false void (async () => { @@ -68,7 +120,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { renderWhitespace: "selection", fontSize: 13, wordWrap: props.wordWrap === "on" ? "on" : "off", - glyphMargin: false, + glyphMargin: true, folding: false, // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. lineNumbersMinChars: 4, @@ -81,6 +133,8 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { }) setReady(true) + + layoutInsertWidget() })() onCleanup(() => { @@ -95,6 +149,74 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") }) + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const modifiedEditor = diffEditor.getModifiedEditor?.() + if (!modifiedEditor?.onDidChangeCursorSelection) return + + const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => { + const selection = event?.selection + if (!selection || selection.isEmpty?.()) { + setSelectedRange(null) + layoutInsertWidget() + return + } + setSelectedRange({ + startLine: Math.min(selection.startLineNumber, selection.endLineNumber), + endLine: Math.max(selection.startLineNumber, selection.endLineNumber), + }) + layoutInsertWidget() + }) + + onCleanup(() => { + try { + disposable?.dispose?.() + } catch { + // ignore + } + }) + }) + + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const modifiedEditor = getModifiedEditor() + if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return + + const moveDisposable = modifiedEditor.onMouseMove((event: any) => { + const lineNumber = event?.target?.position?.lineNumber + setHoveredLine(typeof lineNumber === "number" ? lineNumber : null) + layoutInsertWidget() + }) + + const leaveDisposable = modifiedEditor.onMouseLeave(() => { + if (!widgetHovered()) { + setHoveredLine(null) + } + layoutInsertWidget() + }) + + const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => { + layoutInsertWidget() + }) + + onCleanup(() => { + try { + moveDisposable?.dispose?.() + leaveDisposable?.dispose?.() + scrollDisposable?.dispose?.() + } catch { + // ignore + } + }) + }) + + createEffect(() => { + if (!ready() || !monaco || !diffEditor) return + const activeRange = getActiveInsertRange() + if (!activeRange) setWidgetPosition(null) + layoutInsertWidget() + }) + createEffect(() => { if (!ready() || !monaco || !diffEditor) return const viewMode = props.viewMode === "unified" ? "unified" : "split" @@ -145,5 +267,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { }) }) - return
+ return ( +
+
+ + {(position: () => { top: number; left: number }) => ( +
{ + setWidgetHovered(true) + layoutInsertWidget() + }} + onMouseLeave={() => { + setWidgetHovered(false) + layoutInsertWidget() + }} + > + +
+ )} +
+
+
+ ) } diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 694b9d36..0ead3c1a 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -43,6 +43,7 @@ import RightPanel from "./shell/right-panel/RightPanel" import { useDrawerChrome } from "./shell/useDrawerChrome" import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status" import { Maximize2, ShieldAlert } from "lucide-solid" +import type { PromptInputApi } from "../prompt-input/types" import type { LayoutMode } from "./shell/types" import { @@ -105,6 +106,7 @@ const InstanceShell2: Component = (props) => { const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [now, setNow] = createSignal(Date.now()) + const [sessionPromptApis, setSessionPromptApis] = createSignal>({}) // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) @@ -268,6 +270,19 @@ const InstanceShell2: Component = (props) => { const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id)) + const activePromptInputApi = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + return sessionPromptApis()[sessionId] ?? null + }) + + const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => { + setSessionPromptApis((current) => ({ + ...current, + [sessionId]: api, + })) + } + createEffect(() => { getPermissionAutoAcceptInFlightVersion() @@ -594,6 +609,7 @@ const InstanceShell2: Component = (props) => { onCloseRightDrawer={closeRightDrawer} onPinRightDrawer={pinRightDrawer} onUnpinRightDrawer={unpinRightDrawer} + promptInputApi={activePromptInputApi} setContentEl={setRightDrawerContentEl} /> @@ -656,6 +672,7 @@ const InstanceShell2: Component = (props) => { onCloseRightDrawer={closeRightDrawer} onPinRightDrawer={pinRightDrawer} onUnpinRightDrawer={unpinRightDrawer} + promptInputApi={activePromptInputApi} setContentEl={setRightDrawerContentEl} /> @@ -892,6 +909,7 @@ const InstanceShell2: Component = (props) => { escapeInDebounce={props.escapeInDebounce} isPhoneLayout={isPhoneLayout()} compactPromptLayout={compactPromptLayout()} + registerSessionPromptApi={registerSessionPromptApi} showSidebarToggle={showEmbeddedSidebarToggle()} onSidebarToggle={() => setLeftOpen(true)} forceCompactStatusLayout={showEmbeddedSidebarToggle()} diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index c031ac87..0a9ba0b7 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -10,7 +10,7 @@ import { type Component, } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" -import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" +import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" @@ -19,16 +19,23 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" import type { Instance } from "../../../../types/instance" import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { Session } from "../../../../types/session" +import type { PromptInputApi } from "../../../prompt-input/types" import type { DrawerViewState } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" -import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" +import { + getDefaultWorktreeSlug, + getGitRepoStatus, + getOrCreateWorktreeClient, + getWorktreeSlugForSession, + getWorktrees, +} from "../../../../stores/worktrees" import { requestData } from "../../../../lib/opencode-api" import { serverApi } from "../../../../lib/api-client" import { showConfirmDialog } from "../../../../stores/alerts" import { showToastNotification } from "../../../../lib/notifications" -import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse" import { useGlobalPointerDrag } from "../useGlobalPointerDrag" +import { useGitChanges } from "./useGitChanges" import { RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, @@ -41,7 +48,11 @@ import { RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY, RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY, RIGHT_PANEL_TAB_STORAGE_KEY, readStoredBool, readStoredEnum, @@ -82,6 +93,7 @@ interface RightPanelProps { onCloseRightDrawer: () => void onPinRightDrawer: () => void onUnpinRightDrawer: () => void + promptInputApi: Accessor setContentEl: (el: HTMLElement | null) => void } @@ -133,6 +145,8 @@ const RightPanel: Component = (props) => { const [changesListTouched, setChangesListTouched] = createSignal(false) const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) + const [gitStagedOpen, setGitStagedOpen] = createSignal(true) + const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true) const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) @@ -149,11 +163,28 @@ const RightPanel: Component = (props) => { return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY } + const gitSectionStorageKey = (section: "staged" | "unstaged") => { + const layout = listLayoutKey() + if (section === "staged") { + return layout === "phone" + ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY + : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY + } + return layout === "phone" + ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY + : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY + } + const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => { if (typeof window === "undefined") return window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false") } + const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => { + if (typeof window === "undefined") return + window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false") + } + createEffect(() => { // Refresh persisted visibility when layout changes (phone vs non-phone). const layout = listLayoutKey() @@ -185,6 +216,12 @@ const RightPanel: Component = (props) => { setGitChangesListOpen(true) setGitChangesListTouched(false) } + + const stagedPersisted = readStoredBool(gitSectionStorageKey("staged")) + setGitStagedOpen(stagedPersisted ?? true) + + const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged")) + setGitUnstagedOpen(unstagedPersisted ?? true) }) createEffect(() => { @@ -339,34 +376,56 @@ const RightPanel: Component = (props) => { return getDefaultWorktreeSlug(props.instanceId) }) + const gitChangesWorktreeSlug = createMemo(() => { + if (getGitRepoStatus(props.instanceId) === false) return null + const slug = worktreeSlugForViewer().trim() + return slug ? slug : null + }) + + const gitChangesWorktree = createMemo(() => { + const slug = gitChangesWorktreeSlug() + if (!slug) return null + return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null + }) + + const gitChangesBranchLabel = createMemo(() => { + const branch = gitChangesWorktree()?.branch?.trim() + return branch || null + }) + const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer())) - const [gitStatusEntries, setGitStatusEntries] = createSignal(null) - const [gitStatusLoading, setGitStatusLoading] = createSignal(false) - const [gitStatusError, setGitStatusError] = createSignal(null) - const [gitSelectedPath, setGitSelectedPath] = createSignal(null) - const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false) - const [gitSelectedError, setGitSelectedError] = createSignal(null) - const [gitSelectedBefore, setGitSelectedBefore] = createSignal(null) - const [gitSelectedAfter, setGitSelectedAfter] = createSignal(null) - - const gitMostChangedPath = createMemo(() => { - const entries = gitStatusEntries() - if (!Array.isArray(entries) || entries.length === 0) return null - const candidates = entries.filter((item) => item && item.status !== "deleted") - if (candidates.length === 0) return null - const best = candidates.reduce((currentBest, item) => { - const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0) - const score = (item?.added ?? 0) + (item?.removed ?? 0) - if (score > bestScore) return item - if (score < bestScore) return currentBest - return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest - }, candidates[0]) - return typeof best?.path === "string" ? best.path : null + const { + gitStatusEntries, + gitStatusLoading, + gitStatusError, + gitSelectedItemId, + gitBulkSelectedItemIds, + gitSelectedLoading, + gitSelectedError, + gitSelectedBefore, + gitSelectedAfter, + gitCommitMessage, + gitCommitSubmitting, + gitMostChangedItemId, + setGitCommitMessage, + handleGitRowClick, + refreshGitStatus, + insertGitChangeContext, + submitGitCommit, + stageGitFile, + unstageGitFile, + } = useGitChanges({ + t: props.t, + instanceId: props.instanceId, + rightPanelTab, + worktreeSlug: worktreeSlugForViewer, + isPhoneLayout: props.isPhoneLayout, + promptInputApi: props.promptInputApi, + closeGitList: () => setGitChangesListOpen(false), }) createEffect(() => { - // Reset tab state when worktree context changes. worktreeSlugForViewer() setBrowserPath(".") setBrowserEntries(null) @@ -375,111 +434,8 @@ const RightPanel: Component = (props) => { setBrowserSelectedContent(null) setBrowserSelectedError(null) setBrowserSelectedLoading(false) - - setGitStatusEntries(null) - setGitStatusError(null) - setGitStatusLoading(false) - setGitSelectedPath(null) - setGitSelectedLoading(false) - setGitSelectedError(null) - setGitSelectedBefore(null) - setGitSelectedAfter(null) }) - const loadGitStatus = async (force = false) => { - if (!force && gitStatusEntries() !== null) return - setGitStatusLoading(true) - setGitStatusError(null) - try { - const list = await requestData(browserClient().file.status(), "file.status") - setGitStatusEntries(Array.isArray(list) ? list : []) - } catch (error) { - setGitStatusError(error instanceof Error ? error.message : "Failed to load git status") - setGitStatusEntries([]) - } finally { - setGitStatusLoading(false) - } - } - - async function openGitFile(path: string) { - setGitSelectedPath(path) - setGitSelectedLoading(true) - setGitSelectedError(null) - setGitSelectedBefore(null) - setGitSelectedAfter(null) - - const list = gitStatusEntries() || [] - const entry = list.find((item) => item.path === path) || null - if (entry?.status === "deleted") { - setGitSelectedError("Deleted file diff is not available yet") - setGitSelectedLoading(false) - return - } - - // Phone: treat file selection as a commit action and close the overlay. - if (props.isPhoneLayout()) { - setGitChangesListOpen(false) - } - - try { - const content = await requestData(browserClient().file.read({ path }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null - if (afterText === null) { - throw new Error("Unsupported file type") - } - - setGitSelectedAfter(afterText) - - if (entry?.status === "added") { - setGitSelectedBefore("") - return - } - - const diffText = - typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0 - ? String((content as any).diff) - : (content as any)?.patch - ? buildUnifiedDiffFromSdkPatch((content as any).patch) - : "" - - const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText) - if (beforeText === null) { - throw new Error("Unable to calculate diff for this file") - } - setGitSelectedBefore(beforeText) - } catch (error) { - setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes") - } finally { - setGitSelectedLoading(false) - } - } - - createEffect(() => { - if (rightPanelTab() !== "git-changes") return - const entries = gitStatusEntries() - if (entries === null) return - if (gitSelectedPath()) return - const next = gitMostChangedPath() - if (!next) return - void openGitFile(next) - }) - - const refreshGitStatus = async () => { - await loadGitStatus(true) - const selected = gitSelectedPath() - if (selected) { - void openGitFile(selected) - } - } - const bestDiffFile = createMemo(() => { const diffs = props.activeSessionDiffs() if (!Array.isArray(diffs) || diffs.length === 0) return null @@ -680,21 +636,6 @@ const RightPanel: Component = (props) => { setBrowserSelectedDirty(false) }) - createEffect(() => { - if (rightPanelTab() !== "git-changes") return - if (gitStatusLoading()) return - if (gitStatusEntries() !== null) return - void loadGitStatus() - }) - - createEffect(() => { - if (rightPanelTab() === "git-changes") return - setGitSelectedBefore(null) - setGitSelectedAfter(null) - setGitSelectedLoading(false) - setGitSelectedError(null) - }) - const handleSelectChangesFile = (file: string, closeList: boolean) => { setSelectedFile(file) if (closeList) { @@ -911,12 +852,13 @@ const RightPanel: Component = (props) => { entries={gitStatusEntries} statusLoading={gitStatusLoading} statusError={gitStatusError} - selectedPath={gitSelectedPath} + selectedItemId={gitSelectedItemId} + selectedBulkItemIds={gitBulkSelectedItemIds} selectedLoading={gitSelectedLoading} selectedError={gitSelectedError} selectedBefore={gitSelectedBefore} selectedAfter={gitSelectedAfter} - mostChangedPath={gitMostChangedPath} + mostChangedItemId={gitMostChangedItemId} scopeKey={gitScopeKey} diffViewMode={diffViewMode} diffContextMode={diffContextMode} @@ -924,8 +866,28 @@ const RightPanel: Component = (props) => { onViewModeChange={setDiffViewMode} onContextModeChange={setDiffContextMode} onWordWrapModeChange={setDiffWordWrapMode} - onOpenFile={(path: string) => void openGitFile(path)} + onRowClick={handleGitRowClick} onRefresh={() => void refreshGitStatus()} + onInsertContext={insertGitChangeContext} + onStageFile={stageGitFile} + onUnstageFile={unstageGitFile} + commitMessage={gitCommitMessage} + commitSubmitting={gitCommitSubmitting} + onCommitMessageInput={setGitCommitMessage} + onSubmitCommit={() => void submitGitCommit()} + branchLabel={gitChangesBranchLabel} + stagedOpen={gitStagedOpen} + unstagedOpen={gitUnstagedOpen} + onToggleStagedOpen={() => { + const next = !gitStagedOpen() + setGitStagedOpen(next) + persistGitSectionOpen("staged", next) + }} + onToggleUnstagedOpen={() => { + const next = !gitUnstagedOpen() + setGitUnstagedOpen(next) + persistGitSectionOpen("unstaged", next) + }} listOpen={gitChangesListOpen} onToggleList={toggleGitList} splitWidth={gitChangesSplitWidth} diff --git a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts new file mode 100644 index 00000000..a7e248b7 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts @@ -0,0 +1,148 @@ +import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client" +import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types" + +import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types" + +function normalizeGitChangePath(path: unknown): string { + if (typeof path !== "string") return "" + const normalized = path.replace(/\\+/g, "/").replace(/^\.\//, "").trim() + return normalized +} + +export function normalizeGitChangeStatus(status: unknown): GitChangeStatus { + return typeof status === "string" && status.trim().length > 0 ? status : "modified" +} + +export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry { + return { + path: normalizeGitChangePath(entry?.path), + originalPath: null, + additions: typeof entry?.added === "number" ? entry.added : 0, + deletions: typeof entry?.removed === "number" ? entry.removed : 0, + status: normalizeGitChangeStatus(entry?.status), + } +} + +export function adaptSdkGitStatusEntries( + entries: SdkGitFileStatus[] | null | undefined, + details?: WorktreeGitStatusEntry[] | null, +): GitChangeEntry[] { + const detailsByPath = new Map( + (details ?? []) + .map((entry) => { + const path = normalizeGitChangePath(entry.path) + return path ? [{ ...entry, path }, path] : null + }) + .filter((entry): entry is [WorktreeGitStatusEntry, string] => Boolean(entry)) + .map(([entry, path]) => [path, entry] as const), + ) + const adaptedByPath = new Map() + + for (const entry of entries ?? []) { + const adapted = adaptSdkGitStatusEntry(entry) + if (!adapted.path) continue + const detail = detailsByPath.get(adapted.path) + adaptedByPath.set(adapted.path, { + ...adapted, + originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null, + stagedStatus: detail?.stagedStatus ?? null, + unstagedStatus: detail?.unstagedStatus ?? null, + stagedAdditions: detail?.stagedAdditions ?? 0, + stagedDeletions: detail?.stagedDeletions ?? 0, + unstagedAdditions: detail?.unstagedAdditions ?? 0, + unstagedDeletions: detail?.unstagedDeletions ?? 0, + }) + } + + for (const detail of details ?? []) { + const normalizedPath = normalizeGitChangePath(detail.path) + if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue + adaptedByPath.set(normalizedPath, { + path: normalizedPath, + originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null, + additions: 0, + deletions: 0, + status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified", + stagedStatus: detail.stagedStatus, + unstagedStatus: detail.unstagedStatus, + stagedAdditions: detail.stagedAdditions, + stagedDeletions: detail.stagedDeletions, + unstagedAdditions: detail.unstagedAdditions, + unstagedDeletions: detail.unstagedDeletions, + }) + } + + return Array.from(adaptedByPath.values()).filter((entry) => entry.path.length > 0) +} + +function buildGitChangeListItemId(section: GitChangeSection, path: string): string { + return `${section}:${path}` +} + +function splitGitChangePath(path: string) { + const normalized = normalizeGitChangePath(path) + const lastSlash = normalized.lastIndexOf("/") + if (lastSlash === -1) { + return { displayName: normalized, parentPath: "" } + } + return { + displayName: normalized.slice(lastSlash + 1), + parentPath: normalized.slice(0, lastSlash), + } +} + +export function buildGitChangeListItems(entries: GitChangeEntry[] | null | undefined): GitChangeListItem[] { + if (!Array.isArray(entries)) return [] + + const items: GitChangeListItem[] = [] + for (const entry of entries) { + const pathParts = splitGitChangePath(entry.path) + if (entry.stagedStatus) { + items.push({ + id: buildGitChangeListItemId("staged", entry.path), + path: entry.path, + originalPath: entry.originalPath ?? null, + section: "staged", + status: entry.stagedStatus, + additions: entry.stagedAdditions ?? 0, + deletions: entry.stagedDeletions ?? 0, + entry, + displayName: pathParts.displayName, + parentPath: pathParts.parentPath, + }) + } + if (entry.unstagedStatus) { + items.push({ + id: buildGitChangeListItemId("unstaged", entry.path), + path: entry.path, + originalPath: entry.originalPath ?? null, + section: "unstaged", + status: entry.unstagedStatus, + additions: entry.unstagedAdditions ?? entry.additions, + deletions: entry.unstagedDeletions ?? entry.deletions, + entry, + displayName: pathParts.displayName, + parentPath: pathParts.parentPath, + }) + } + if (!entry.stagedStatus && !entry.unstagedStatus) { + items.push({ + id: buildGitChangeListItemId("unstaged", entry.path), + path: entry.path, + originalPath: entry.originalPath ?? null, + section: "unstaged", + status: entry.status, + additions: entry.additions, + deletions: entry.deletions, + entry, + displayName: pathParts.displayName, + parentPath: pathParts.parentPath, + }) + } + } + + return items.sort((a, b) => { + if (a.section !== b.section) return a.section.localeCompare(b.section) + return a.path.localeCompare(b.path) + }) +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx index 019dce1d..c7da139c 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -1,11 +1,20 @@ -import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" -import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" +import { + For, + Show, + Suspense, + createMemo, + lazy, + type Accessor, + type Component, + type JSX, +} from "solid-js" -import { RefreshCw } from "lucide-solid" +import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid" import DiffToolbar from "../components/DiffToolbar" import SplitFilePanel from "../components/SplitFilePanel" -import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types" +import { buildGitChangeListItems } from "../git-changes-model" const LazyMonacoDiffViewer = lazy(() => import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })), @@ -16,16 +25,17 @@ interface GitChangesTabProps { activeSessionId: Accessor - entries: Accessor + entries: Accessor statusLoading: Accessor statusError: Accessor - selectedPath: Accessor + selectedItemId: Accessor + selectedBulkItemIds: Accessor> selectedLoading: Accessor selectedError: Accessor selectedBefore: Accessor selectedAfter: Accessor - mostChangedPath: Accessor + mostChangedItemId: Accessor scopeKey: Accessor @@ -36,8 +46,21 @@ interface GitChangesTabProps { onContextModeChange: (mode: DiffContextMode) => void onWordWrapModeChange: (mode: DiffWordWrapMode) => void - onOpenFile: (path: string) => void + onRowClick: (item: GitChangeListItem, event: MouseEvent) => void onRefresh: () => void + onInsertContext: (item: GitChangeListItem, selection: { startLine: number; endLine: number }) => void + onStageFile: (item: GitChangeListItem) => void + onUnstageFile: (item: GitChangeListItem) => void + commitMessage: Accessor + commitSubmitting: Accessor + onCommitMessageInput: (value: string) => void + onSubmitCommit: () => void + branchLabel: Accessor + + stagedOpen: Accessor + unstagedOpen: Accessor + onToggleStagedOpen: () => void + onToggleUnstagedOpen: () => void listOpen: Accessor onToggleList: () => void @@ -52,48 +75,54 @@ const GitChangesTab: Component = (props) => { const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) const entries = createMemo(() => (hasSession() ? props.entries() : null)) - const sorted = createMemo(() => { + const sorted = createMemo(() => { const list = entries() if (!Array.isArray(list)) return [] return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) }) + const listItems = createMemo(() => buildGitChangeListItems(sorted())) + const totals = createMemo(() => { - return sorted().reduce( + return listItems().reduce( (acc, item) => { - acc.additions += typeof item.added === "number" ? item.added : 0 - acc.deletions += typeof item.removed === "number" ? item.removed : 0 + acc.additions += typeof item.additions === "number" ? item.additions : 0 + acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 return acc }, { additions: 0, deletions: 0 }, ) }) + const stagedItems = createMemo(() => listItems().filter((item) => item.section === "staged")) + const unstagedItems = createMemo(() => listItems().filter((item) => item.section === "unstaged")) + const canCommit = createMemo(() => stagedItems().length > 0 && props.commitMessage().trim().length > 0 && !props.commitSubmitting()) - const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted")) - - const selectedEntry = createMemo(() => { - const list = sorted() - const selectedPath = props.selectedPath() - const fallbackPath = props.mostChangedPath() + const selectedEntry = createMemo(() => { + const list = listItems() + const selectedId = props.selectedItemId() + const fallbackId = props.mostChangedItemId() const found = - list.find((item) => item.path === selectedPath) || - (fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined) - return found ?? null + list.find((item) => item.id === selectedId) || + (fallbackId ? list.find((item) => item.id === fallbackId) : undefined) + return found?.entry ?? null }) const emptyViewerMessage = createMemo(() => { if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected") const currentEntries = entries() if (currentEntries === null) return props.t("instanceShell.gitChanges.loading") - if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty") + if (listItems().length === 0) return props.t("instanceShell.gitChanges.empty") return props.t("instanceShell.filesShell.viewerEmpty") }) + const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer")) + const renderContent = (): JSX.Element => { const totalsValue = totals() const selected = selectedEntry() - const sortedList = sorted() - const nonDeletedList = nonDeleted() + const allItems = listItems() + const stagedList = stagedItems() + const unstagedList = unstagedItems() const renderViewer = () => (
@@ -109,7 +138,7 @@ const GitChangesTab: Component = (props) => { selected && props.selectedBefore() !== null && props.selectedAfter() !== null && - selected.status !== "deleted" + true ? { path: selected.path, before: props.selectedBefore() as string, @@ -139,6 +168,14 @@ const GitChangesTab: Component = (props) => { viewMode={props.diffViewMode()} contextMode={props.diffContextMode()} wordWrap={props.diffWordWrapMode()} + insertContextLabel={props.t("instanceShell.gitChanges.actions.insertContext")} + onRequestInsertContext={binaryViewerActive() ? undefined : (selection) => { + const selectedId = props.selectedItemId() + if (!selectedId) return + const item = listItems().find((entry) => entry.id === selectedId) + if (!item) return + props.onInsertContext(item, selection) + }} /> )} @@ -163,66 +200,149 @@ const GitChangesTab: Component = (props) => { const renderEmptyList = () =>
{emptyViewerMessage()}
- const renderListPanel = () => ( - 0} fallback={renderEmptyList()}> - - {(item) => ( -
{ - props.onOpenFile(item.path) - }} - > -
-
- {item.path} -
-
- - {props.t("instanceShell.gitChanges.deleted")} - - - <> - +{item.added} - -{item.removed} - - -
+ const renderListItem = (item: GitChangeListItem) => { + const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id)) + const actionLabel = + item.section === "staged" + ? props.t("instanceShell.gitChanges.actions.unstage") + : props.t("instanceShell.gitChanges.actions.stage") + + const triggerAction = () => { + if (item.section === "staged") props.onUnstageFile(item) + else props.onStageFile(item) + } + + return ( +
{ + if (event.shiftKey || event.ctrlKey || event.metaKey) { + event.preventDefault() + } + }} + onClick={(event) => props.onRowClick(item, event)} + title={item.path} + > +
+
+ {item.path} +
+
+
+ +{item.additions} + -{item.deletions}
- )} - - +
+
+
+ +
+
+
+ ) + } + + const renderSection = ( + title: string, + items: GitChangeListItem[], + isOpen: boolean, + onToggle: () => void, + ) => ( +
+ + +
+ {(item) => renderListItem(item)} +
+
+
) - const renderListOverlay = () => ( - 0} fallback={renderEmptyList()}> - - {(item) => ( -
props.onOpenFile(item.path)} - title={item.path} - > -
-
- {item.path} -
-
- - {props.t("instanceShell.gitChanges.deleted")} - - - <> - +{item.added} - -{item.removed} - + const renderGroupedList = () => ( + 0} fallback={renderEmptyList()}> +
+
+ + +
+
+
+