Archived
Add workspace side panel with files and git
This commit is contained in:
@@ -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 }); });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { gitDiff, gitStatus } from "./git/gitService.js";
|
||||
|
||||
export function registerGitRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
|
||||
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/status", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await gitStatus(context.root);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string; staged?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/git/diff", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await gitDiff(context.root, { ...(request.query.path === undefined ? {} : { path: request.query.path }), staged: request.query.staged === "true" });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
@@ -35,6 +37,8 @@ app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces"
|
||||
});
|
||||
|
||||
registerSessionProxyRoutes(app);
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||
registerGitRoutes(app, projects, workspaces);
|
||||
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import { listWorkspaceTree } from "./workspaces/fileTreeService.js";
|
||||
import { readWorkspaceFile } from "./workspaces/fileContentService.js";
|
||||
|
||||
export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService): void {
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/tree", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await listWorkspaceTree(context.root, request.query.path);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>("/api/projects/:projectId/workspaces/:workspaceId/file", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await readWorkspaceFile(context.root, request.query.path);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
export interface FileContentResponse {
|
||||
path: string;
|
||||
language?: string;
|
||||
encoding: "utf8";
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
export async function readWorkspaceFile(rootPath: string, path: string | undefined): Promise<FileContentResponse> {
|
||||
if (path === undefined || path === "") throw new Error("path query parameter is required");
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const s = await stat(target);
|
||||
if (!s.isFile()) throw new Error("Path is not a file");
|
||||
const bytesToRead = Math.min(s.size, MAX_BYTES);
|
||||
const buffer = (await readFile(target)).subarray(0, bytesToRead);
|
||||
const binary = isProbablyBinary(buffer);
|
||||
return {
|
||||
path: relativePath,
|
||||
...languageForPath(relativePath),
|
||||
encoding: "utf8",
|
||||
size: s.size,
|
||||
modifiedAt: s.mtime.toISOString(),
|
||||
content: binary ? "" : buffer.toString("utf8"),
|
||||
truncated: s.size > MAX_BYTES,
|
||||
binary,
|
||||
};
|
||||
}
|
||||
|
||||
function isProbablyBinary(buffer: Buffer): boolean {
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
|
||||
return sample.includes(0);
|
||||
}
|
||||
|
||||
function languageForPath(path: string): { language?: string } {
|
||||
const ext = path.split(".").pop()?.toLowerCase();
|
||||
const language = ext === undefined ? undefined : ({
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
css: "css",
|
||||
html: "html",
|
||||
py: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
sh: "shell",
|
||||
yml: "yaml",
|
||||
yaml: "yaml",
|
||||
} as Record<string, string | undefined>)[ext];
|
||||
return language === undefined ? {} : { language };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { resolveInsideWorkspace } from "./pathSafety.js";
|
||||
|
||||
export interface FileTreeEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory" | "symlink";
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface FileTreeResponse {
|
||||
path: string;
|
||||
entries: FileTreeEntry[];
|
||||
scannedAt: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 1000;
|
||||
|
||||
export async function listWorkspaceTree(rootPath: string, path: string | undefined): Promise<FileTreeResponse> {
|
||||
const { target, relativePath } = await resolveInsideWorkspace(rootPath, path);
|
||||
const stat = await lstat(target);
|
||||
if (!stat.isDirectory()) throw new Error("Path is not a directory");
|
||||
|
||||
const dirents = await readdir(target, { withFileTypes: true });
|
||||
const visible = dirents.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules").sort((a, b) => {
|
||||
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const selected = visible.slice(0, MAX_ENTRIES);
|
||||
const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => {
|
||||
const absolute = join(target, entry.name);
|
||||
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`;
|
||||
const childStat = await lstat(absolute);
|
||||
const type: FileTreeEntry["type"] = entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file";
|
||||
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
|
||||
}));
|
||||
|
||||
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: visible.length > selected.length };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, sep } from "node:path";
|
||||
|
||||
export async function resolveInsideWorkspace(rootPath: string, relativePath: string | undefined): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
const joined = join(root, requested);
|
||||
const target = await realpath(joined);
|
||||
ensureInside(root, target);
|
||||
return { root, target, relativePath: requested };
|
||||
}
|
||||
|
||||
export async function resolveParentInsideWorkspace(rootPath: string, relativePath: string): Promise<{ root: string; target: string; relativePath: string }> {
|
||||
const requested = normalizeRelativePath(relativePath);
|
||||
const root = await realpath(rootPath);
|
||||
const target = join(root, requested);
|
||||
ensureInside(root, target);
|
||||
return { root, target, relativePath: requested };
|
||||
}
|
||||
|
||||
export function normalizeRelativePath(input: string | undefined): string {
|
||||
const value = input ?? "";
|
||||
if (value === "" || value === ".") return "";
|
||||
if (isAbsolute(value)) throw new Error("Absolute paths are not allowed");
|
||||
const parts = value.split(/[\\/]+/).filter((part) => part !== "" && part !== ".");
|
||||
if (parts.some((part) => part === "..")) throw new Error("Path traversal is not allowed");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function ensureInside(root: string, target: string): void {
|
||||
const rel = relative(root, target);
|
||||
if (rel === "") return;
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Path escapes workspace");
|
||||
if (sep !== "/" && rel.split(sep).includes("..")) throw new Error("Path escapes workspace");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ProjectService } from "../projects/projectService.js";
|
||||
import type { Project, Workspace } from "../types.js";
|
||||
import type { WorkspaceService } from "./workspaceService.js";
|
||||
|
||||
export interface WorkspaceContext {
|
||||
project: Project;
|
||||
workspace: Workspace;
|
||||
root: string;
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceContext(projects: ProjectService, workspaces: WorkspaceService, projectId: string, workspaceId: string): Promise<WorkspaceContext> {
|
||||
const project = await projects.requireProject(projectId);
|
||||
const workspace = (await workspaces.list(project)).find((candidate) => candidate.id === workspaceId);
|
||||
if (!workspace) throw new Error("Workspace not found");
|
||||
return { project, workspace, root: workspace.path };
|
||||
}
|
||||
Reference in New Issue
Block a user