Add workspace side panel with files and git

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 23:27:38 +02:00
parent 34994705e4
commit ba8f74bee2
15 changed files with 799 additions and 6 deletions
+126
View File
@@ -0,0 +1,126 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { normalizeRelativePath } from "../workspaces/pathSafety.js";
export type GitFileState = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted";
export interface GitStatusFile {
path: string;
oldPath?: string;
index: GitFileState;
workingTree: GitFileState;
}
export interface GitStatusResponse {
isGitRepo: boolean;
hash: string;
branch?: string;
upstream?: string;
ahead?: number;
behind?: number;
files: GitStatusFile[];
}
export interface GitDiffResponse {
path?: string;
staged: boolean;
hash: string;
diff: string;
truncated: boolean;
}
const MAX_OUTPUT = 2 * 1024 * 1024;
export async function gitStatus(cwd: string): Promise<GitStatusResponse> {
const result = await runGit(cwd, ["status", "--porcelain=v2", "--branch", "-z"]);
if (result.code !== 0) return { isGitRepo: false, hash: hash(result.stdout + result.stderr), files: [] };
return parseStatus(result.stdout);
}
export async function gitDiff(cwd: string, options: { path?: string; staged?: boolean }): Promise<GitDiffResponse> {
const staged = options.staged === true;
const args = ["diff", "--no-ext-diff", "--color=never"];
if (staged) args.push("--cached");
let path: string | undefined;
if (options.path !== undefined && options.path !== "") {
path = normalizeRelativePath(options.path);
args.push("--", path);
}
const result = await runGit(cwd, args);
if (result.code !== 0) throw new Error(result.stderr.trim() || "git diff failed");
return { ...(path === undefined ? {} : { path }), staged, hash: hash(result.stdout), diff: result.stdout, truncated: result.truncated };
}
function parseStatus(raw: string): GitStatusResponse {
const records = raw.split("\0").filter((record) => record !== "");
const files: GitStatusFile[] = [];
let branch: string | undefined;
let upstream: string | undefined;
let ahead: number | undefined;
let behind: number | undefined;
for (let i = 0; i < records.length; i += 1) {
const record = records[i];
if (record === undefined) continue;
if (record.startsWith("# branch.head ")) branch = normalizeBranch(record.slice("# branch.head ".length));
else if (record.startsWith("# branch.upstream ")) upstream = record.slice("# branch.upstream ".length);
else if (record.startsWith("# branch.ab ")) {
const match = /\+(\d+) -(\d+)/.exec(record);
if (match) { ahead = Number(match[1]); behind = Number(match[2]); }
} else if (record.startsWith("? ")) files.push({ path: record.slice(2), index: "untracked", workingTree: "untracked" });
else if (record.startsWith("! ")) files.push({ path: record.slice(2), index: "ignored", workingTree: "ignored" });
else if (record.startsWith("1 ")) {
const parts = record.split(" ");
files.push({ path: parts.slice(8).join(" "), index: stateFor(parts[1]?.[0]), workingTree: stateFor(parts[1]?.[1]) });
} else if (record.startsWith("2 ")) {
const parts = record.split(" ");
const path = parts.slice(9).join(" ");
const oldPath = records[i + 1];
i += 1;
files.push({ path, ...(oldPath === undefined ? {} : { oldPath }), index: stateFor(parts[1]?.[0]), workingTree: stateFor(parts[1]?.[1]) });
} else if (record.startsWith("u ")) {
const parts = record.split(" ");
files.push({ path: parts.slice(10).join(" "), index: "conflicted", workingTree: "conflicted" });
}
}
return { isGitRepo: true, hash: hash(raw), ...(branch === undefined ? {} : { branch }), ...(upstream === undefined ? {} : { upstream }), ...(ahead === undefined ? {} : { ahead }), ...(behind === undefined ? {} : { behind }), files };
}
function stateFor(code: string | undefined): GitFileState {
switch (code) {
case ".": return "unmodified";
case "M": return "modified";
case "A": return "added";
case "D": return "deleted";
case "R": return "renamed";
case "C": return "copied";
case "U": return "conflicted";
default: return "unmodified";
}
}
function normalizeBranch(value: string): string | undefined {
return value === "(detached)" ? undefined : value;
}
function hash(value: string): string {
return createHash("sha1").update(value).digest("hex");
}
async function runGit(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string; truncated: boolean }> {
return new Promise((resolve, reject) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
const timer = setTimeout(() => { child.kill("SIGKILL"); }, 10000);
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let truncated = false;
child.stdout.on("data", (chunk: Buffer) => {
if (stdout.length + chunk.length > MAX_OUTPUT) truncated = true;
if (stdout.length < MAX_OUTPUT) stdout = Buffer.concat([stdout, chunk]).subarray(0, MAX_OUTPUT);
});
child.stderr.on("data", (chunk: Buffer) => { stderr = Buffer.concat([stderr, chunk]).subarray(0, 64 * 1024); });
child.on("error", (error) => { clearTimeout(timer); reject(error); });
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), truncated }); });
});
}