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
@@ -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 };
}
+42
View File
@@ -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 };
}
+35
View File
@@ -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");
}
+16
View File
@@ -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 };
}