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
+103
View File
@@ -60,6 +60,59 @@ export interface FileSuggestion {
kind: "tracked" | "untracked" | "other"; kind: "tracked" | "untracked" | "other";
} }
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;
}
export interface FileContentResponse {
path: string;
language?: string;
encoding: "utf8";
size: number;
modifiedAt: string;
content: string;
truncated: boolean;
binary: boolean;
}
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;
}
export interface CommandOption { export interface CommandOption {
value: string; value: string;
label: string; label: string;
@@ -104,6 +157,10 @@ export const api = {
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus), status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)), commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)), files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }), prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }), shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }), runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
@@ -122,6 +179,14 @@ export function globalSessionEvents(): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`); return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
} }
function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
const params = new URLSearchParams();
if (options?.path !== undefined) params.set("path", options.path);
if (options?.staged === true) params.set("staged", "true");
const query = params.toString();
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
}
function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string { function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.limit !== undefined) params.set("limit", String(options.limit));
@@ -277,6 +342,44 @@ function parseFileSuggestion(value: unknown): FileSuggestion {
return { path: requireString(record, "path"), kind }; return { path: requireString(record, "path"), kind };
} }
function parseFileTreeResponse(value: unknown): FileTreeResponse {
const record = requireRecord(value);
return { path: requireString(record, "path"), entries: arrayOf(parseFileTreeEntry)(record["entries"]), scannedAt: requireString(record, "scannedAt"), truncated: requireBoolean(record, "truncated") };
}
function parseFileTreeEntry(value: unknown): FileTreeEntry {
const record = requireRecord(value);
const type = requireString(record, "type");
if (type !== "file" && type !== "directory" && type !== "symlink") throw new Error("Invalid file tree entry type");
return { name: requireString(record, "name"), path: requireString(record, "path"), type, ...optionalField("size", optionalNumber(record, "size")), ...optionalField("modifiedAt", optionalString(record, "modifiedAt")) };
}
function parseFileContentResponse(value: unknown): FileContentResponse {
const record = requireRecord(value);
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding: requireString(record, "encoding") as "utf8", size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
}
function parseGitStatusResponse(value: unknown): GitStatusResponse {
const record = requireRecord(value);
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]) };
}
function parseGitStatusFile(value: unknown): GitStatusFile {
const record = requireRecord(value);
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]) };
}
function parseGitFileState(value: unknown): GitFileState {
if (typeof value !== "string") throw new Error("Expected git file state");
if (!["unmodified", "modified", "added", "deleted", "renamed", "copied", "untracked", "ignored", "conflicted"].includes(value)) throw new Error("Invalid git file state");
return value as GitFileState;
}
function parseGitDiffResponse(value: unknown): GitDiffResponse {
const record = requireRecord(value);
return { ...optionalField("path", optionalString(record, "path")), staged: requireBoolean(record, "staged"), hash: requireString(record, "hash"), diff: requireString(record, "diff"), truncated: requireBoolean(record, "truncated") };
}
function parseCommandResult(value: unknown): CommandResult { function parseCommandResult(value: unknown): CommandResult {
const record = requireRecord(value); const record = requireRecord(value);
const type = requireString(record, "type"); const type = requireString(record, "type");
+23 -1
View File
@@ -1,4 +1,4 @@
import type { CommandResult, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api"; import type { CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared"; import type { ChatLine } from "./components/shared";
export interface AppState { export interface AppState {
@@ -17,6 +17,17 @@ export interface AppState {
sessionStatuses: Record<string, SessionStatus>; sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>; sessionActivities: Record<string, SessionActivity>;
commandDialog: Extract<CommandResult, { type: "select" }> | undefined; commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
workspaceTool: "files" | "git";
mainView: "chat" | "files" | "git";
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined;
selectedFileContent: FileContentResponse | undefined;
fileTreeStale: boolean;
gitStatus: GitStatusResponse | undefined;
selectedDiffPath: string | undefined;
selectedDiff: GitDiffResponse | undefined;
gitStale: boolean;
error: string; error: string;
} }
@@ -37,6 +48,17 @@ export function initialAppState(): AppState {
sessionStatuses: {}, sessionStatuses: {},
sessionActivities: {}, sessionActivities: {},
commandDialog: undefined, commandDialog: undefined,
workspaceTool: "files",
mainView: "chat",
fileTree: [],
expandedDirs: {},
selectedFilePath: undefined,
selectedFileContent: undefined,
fileTreeStale: false,
gitStatus: undefined,
selectedDiffPath: undefined,
selectedDiff: undefined,
gitStale: false,
error: "", error: "",
}; };
} }
+149 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
import type { Project, SessionInfo, Workspace } from "../api"; import { api, type Project, type SessionInfo, type Workspace } from "../api";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { ProjectController } from "../controllers/projectController"; import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController"; import { SessionController } from "../controllers/sessionController";
@@ -15,6 +15,7 @@ import "./PromptEditor";
import type { PromptEditor } from "./PromptEditor"; import type { PromptEditor } from "./PromptEditor";
import "./StatusBar"; import "./StatusBar";
import "./CommandPicker"; import "./CommandPicker";
import "./WorkspacePanel";
import { appStyles } from "./shared"; import { appStyles } from "./shared";
@customElement("pi-web-poc") @customElement("pi-web-poc")
@@ -40,6 +41,7 @@ export class PiWebApp extends LitElement {
this.workspaces, this.workspaces,
); );
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private gitPollTimer: number | undefined;
override connectedCallback(): void { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
@@ -51,11 +53,15 @@ export class PiWebApp extends LitElement {
override disconnectedCallback(): void { override disconnectedCallback(): void {
window.removeEventListener("popstate", this.onPopState); window.removeEventListener("popstate", this.onPopState);
this.sessions.dispose(); this.sessions.dispose();
if (this.gitPollTimer !== undefined) window.clearInterval(this.gitPollTimer);
super.disconnectedCallback(); super.disconnectedCallback();
} }
private setState(patch: Partial<AppState>) { private setState(patch: Partial<AppState>) {
const previous = this.state;
this.state = { ...this.state, ...patch }; this.state = { ...this.state, ...patch };
this.handleActivityTransition(previous, this.state);
this.handleWorkspaceChange(previous, this.state);
} }
private async loadProjectsAndRestoreRoute() { private async loadProjectsAndRestoreRoute() {
@@ -65,10 +71,16 @@ export class PiWebApp extends LitElement {
private async restoreRoute(updateUrl: boolean) { private async restoreRoute(updateUrl: boolean) {
const route = readRoute(); const route = readRoute();
this.setState({ workspaceTool: route.tool ?? this.state.workspaceTool, mainView: route.view ?? this.state.mainView, selectedFilePath: route.file, selectedDiffPath: route.diff });
if (route.projectId === undefined || route.projectId === "") return; if (route.projectId === undefined || route.projectId === "") return;
const project = this.state.projects.find((p) => p.id === route.projectId); const project = this.state.projects.find((p) => p.id === route.projectId);
if (!project) return; if (!project) return;
await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl });
if (route.tool === "files") await this.refreshFiles();
if (route.file !== undefined) await this.selectFile(route.file);
if (route.tool === "git") await this.refreshGit();
if (route.diff !== undefined) await this.selectDiff(route.diff);
this.updateGitPolling();
} }
private async withChatScrollTransition(action: () => Promise<void>) { private async withChatScrollTransition(action: () => Promise<void>) {
@@ -95,9 +107,133 @@ export class PiWebApp extends LitElement {
projectId: this.state.selectedProject?.id, projectId: this.state.selectedProject?.id,
workspaceId: this.state.selectedWorkspace?.id, workspaceId: this.state.selectedWorkspace?.id,
sessionId: this.state.selectedSession?.id, sessionId: this.state.selectedSession?.id,
tool: this.state.workspaceTool,
view: this.state.mainView,
file: this.state.selectedFilePath,
diff: this.state.selectedDiffPath,
}); });
} }
private selectWorkspaceTool(tool: "files" | "git") {
this.setState({ workspaceTool: tool, mainView: tool });
this.updateUrl();
if (tool === "files") void this.refreshFiles();
else void this.refreshGit();
this.updateGitPolling();
}
private selectMainView(view: "chat" | "files" | "git") {
this.setState({ mainView: view, workspaceTool: view === "chat" ? this.state.workspaceTool : view });
this.updateUrl();
if (view === "files") void this.refreshFiles();
if (view === "git") void this.refreshGit();
this.updateGitPolling();
}
private async refreshFiles() {
const project = this.state.selectedProject;
const workspace = this.state.selectedWorkspace;
if (!project || !workspace) return;
try {
const root = await api.workspaceTree(project.id, workspace.id);
const expanded = { ...this.state.expandedDirs };
await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path)).entries; }));
this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async expandDir(path: string) {
const project = this.state.selectedProject;
const workspace = this.state.selectedWorkspace;
if (!project || !workspace) return;
if (this.state.expandedDirs[path] !== undefined) {
const next = { ...this.state.expandedDirs };
delete next[path];
this.setState({ expandedDirs: next });
return;
}
try {
const response = await api.workspaceTree(project.id, workspace.id, path);
this.setState({ expandedDirs: { ...this.state.expandedDirs, [path]: response.entries }, error: "" });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async selectFile(path: string) {
const project = this.state.selectedProject;
const workspace = this.state.selectedWorkspace;
if (!project || !workspace) return;
this.setState({ selectedFilePath: path, selectedFileContent: undefined, workspaceTool: "files", mainView: this.state.mainView === "chat" ? "chat" : "files" });
this.updateUrl();
try {
this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async refreshGit() {
const project = this.state.selectedProject;
const workspace = this.state.selectedWorkspace;
if (!project || !workspace) return;
try {
const status = await api.gitStatus(project.id, workspace.id);
this.setState({ gitStatus: status, gitStale: false, error: "" });
if (this.state.selectedDiffPath !== undefined && status.files.some((file) => file.path === this.state.selectedDiffPath)) await this.refreshDiff(this.state.selectedDiffPath);
} catch (error) {
this.setState({ error: String(error) });
}
}
private async selectDiff(path: string) {
this.setState({ selectedDiffPath: path, selectedDiff: undefined, workspaceTool: "git", mainView: this.state.mainView === "chat" ? "chat" : "git" });
this.updateUrl();
await this.refreshDiff(path);
}
private async refreshDiff(path: string) {
const project = this.state.selectedProject;
const workspace = this.state.selectedWorkspace;
if (!project || !workspace) return;
try {
this.setState({ selectedDiff: await api.gitDiff(project.id, workspace.id, { path }), error: "" });
} catch (error) {
this.setState({ error: String(error) });
}
}
private handleWorkspaceChange(previous: AppState, next: AppState) {
if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id || next.selectedWorkspace === undefined) return;
if (next.workspaceTool === "files") void this.refreshFiles();
if (next.workspaceTool === "git") void this.refreshGit();
this.updateGitPolling();
}
private handleActivityTransition(previous: AppState, next: AppState) {
const wasActive = isActive(previous.status);
const nowActive = isActive(next.status);
if (wasActive && !nowActive) {
this.setState({ fileTreeStale: true, gitStale: true });
if (this.state.workspaceTool === "files") void this.refreshFiles();
if (this.state.workspaceTool === "git") void this.refreshGit();
}
}
private updateGitPolling() {
if (this.gitPollTimer !== undefined) window.clearInterval(this.gitPollTimer);
this.gitPollTimer = undefined;
if (this.state.workspaceTool === "git" || this.state.mainView === "git") {
this.gitPollTimer = window.setInterval(() => { void this.refreshGit(); }, 8000);
}
}
private renderWorkspacePanel() {
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: "files" | "git") => this.selectWorkspaceTool(tool)} .onRefreshFiles=${() => this.refreshFiles()} .onExpandDir=${(path: string) => this.expandDir(path)} .onSelectFile=${(path: string) => this.selectFile(path)} .onRefreshGit=${() => this.refreshGit()} .onSelectDiff=${(path: string) => this.selectDiff(path)}></workspace-panel>`;
}
override render() { override render() {
const state = this.state; const state = this.state;
return html` return html`
@@ -111,7 +247,12 @@ export class PiWebApp extends LitElement {
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list> <workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => this.sessions.restoreSession(session)}></session-list> <session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => this.sessions.restoreSession(session)}></session-list>
</aside> </aside>
<main> <main class=${`${state.mainView}-view`}>
<div class="mobile-tabs">
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => this.selectMainView("chat")}>Chat</button>
<button class=${state.mainView === "files" ? "selected" : ""} @click=${() => this.selectMainView("files")}>Files</button>
<button class=${state.mainView === "git" ? "selected" : ""} @click=${() => this.selectMainView("git")}>Git</button>
</div>
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
${state.selectedSession ? html` ${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> <chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
@@ -119,7 +260,9 @@ export class PiWebApp extends LitElement {
<status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar> <status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null} ${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
` : html`<div class="empty">Select or start a session.</div>`} ` : html`<div class="empty">Select or start a session.</div>`}
<div class="mobile-panel">${this.renderWorkspacePanel()}</div>
</main> </main>
${this.renderWorkspacePanel()}
</div> </div>
`; `;
} }
@@ -127,6 +270,10 @@ export class PiWebApp extends LitElement {
static override styles = appStyles; static override styles = appStyles;
} }
function isActive(status: AppState["status"]): boolean {
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
}
function nextFrame(): Promise<void> { function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
} }
+123
View File
@@ -0,0 +1,123 @@
import { LitElement, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
import { workspacePanelStyles } from "./shared";
@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@property() tool: "files" | "git" = "files";
@property({ attribute: false }) fileTree: FileTreeEntry[] = [];
@property({ attribute: false }) expandedDirs: Record<string, FileTreeEntry[]> = {};
@property({ attribute: false }) selectedFilePath: string | undefined;
@property({ attribute: false }) selectedFileContent: FileContentResponse | undefined;
@property({ type: Boolean }) fileTreeStale = false;
@property({ attribute: false }) gitStatus: GitStatusResponse | undefined;
@property({ attribute: false }) selectedDiffPath: string | undefined;
@property({ attribute: false }) selectedDiff: GitDiffResponse | undefined;
@property({ type: Boolean }) gitStale = false;
@property({ attribute: false }) onSelectTool: (tool: "files" | "git") => void = () => undefined;
@property({ attribute: false }) onRefreshFiles: () => void = () => undefined;
@property({ attribute: false }) onExpandDir: (path: string) => void = () => undefined;
@property({ attribute: false }) onSelectFile: (path: string) => void = () => undefined;
@property({ attribute: false }) onRefreshGit: () => void = () => undefined;
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
override render() {
if (!this.workspace) return html`<section class="empty">Select a workspace.</section>`;
return html`
<header>
<div class="tabs">
<button class=${this.tool === "files" ? "selected" : ""} @click=${() => this.onSelectTool("files")}>Files</button>
<button class=${this.tool === "git" ? "selected" : ""} @click=${() => this.onSelectTool("git")}>Git</button>
</div>
<small title=${this.workspace.path}>${this.workspace.label}</small>
</header>
${this.tool === "files" ? this.renderFiles() : this.renderGit()}
`;
}
private renderFiles() {
return html`
<section class="toolbar">
<strong>Files</strong>
${this.fileTreeStale ? html`<span class="stale">stale</span>` : null}
<button @click=${this.onRefreshFiles}>Refresh</button>
</section>
<section class="split">
<div class="list tree">
${this.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : this.fileTree.map((entry) => this.renderTreeEntry(entry, 0))}
</div>
<div class="viewer">
${this.renderFileViewer()}
</div>
</section>
`;
}
private renderTreeEntry(entry: FileTreeEntry, depth: number): TemplateResult {
const children = this.expandedDirs[entry.path];
return html`
<button class="row" style=${`--depth:${depth}`} @click=${() => entry.type === "directory" ? this.onExpandDir(entry.path) : this.onSelectFile(entry.path)}>
<span>${entry.type === "directory" ? (children ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${children ? children.map((child) => this.renderTreeEntry(child, depth + 1)) : null}
`;
}
private renderFileViewer() {
const file = this.selectedFileContent;
if (!this.selectedFilePath) return html`<p class="muted">Select a file.</p>`;
if (!file) return html`<p class="muted">Loading ${this.selectedFilePath}…</p>`;
if (file.binary) return html`<p class="muted">Binary file: ${file.path}</p>`;
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
<pre><code>${file.content}</code></pre>
`;
}
private renderGit() {
const status = this.gitStatus;
return html`
<section class="toolbar">
<strong>Git</strong>
${this.gitStale ? html`<span class="stale">stale</span>` : null}
<button @click=${this.onRefreshGit}>Refresh</button>
</section>
<section class="split">
<div class="list">
${status === undefined ? html`<p class="muted">No status loaded.</p>` : status.isGitRepo === false ? html`<p class="muted">Not a git repository.</p>` : html`
<p class="summary">${status.branch ?? "detached"}${status.ahead || status.behind ? ` · ↑${status.ahead ?? 0}${status.behind ?? 0}` : ""}</p>
${status.files.length === 0 ? html`<p class="muted">No changes.</p>` : status.files.map((file) => html`
<button class="row ${this.selectedDiffPath === file.path ? "selected" : ""}" @click=${() => this.onSelectDiff(file.path)}>
<span>${stateLabel(file.index, file.workingTree)}</span>
<span>${file.path}</span>
</button>
`)}
`}
</div>
<div class="viewer">
${this.renderDiffViewer()}
</div>
</section>
`;
}
private renderDiffViewer() {
if (!this.selectedDiffPath) return html`<p class="muted">Select a changed file.</p>`;
const diff = this.selectedDiff;
if (!diff) return html`<p class="muted">Loading diff…</p>`;
return html`
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<pre><code>${diff.diff || "No unstaged diff."}</code></pre>
`;
}
static override styles = workspacePanelStyles;
}
function stateLabel(index: string, workingTree: string): string {
const label = workingTree !== "unmodified" ? workingTree : index;
return label.slice(0, 1).toUpperCase();
}
+40 -1
View File
@@ -25,12 +25,27 @@ export interface CompletionItem {
export const appStyles = css` export const appStyles = css`
:host { display: block; height: 100dvh; box-sizing: border-box; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: #e6edf3; background: #0d1117; font: 14px system-ui, sans-serif; } :host { display: block; height: 100dvh; box-sizing: border-box; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: #e6edf3; background: #0d1117; font: 14px system-ui, sans-serif; }
.shell { display: grid; grid-template-columns: 340px 1fr; height: 100%; min-height: 0; } .shell { display: grid; grid-template-columns: 340px minmax(420px, 1fr) minmax(360px, 42vw); height: 100%; min-height: 0; }
aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid #30363d; overflow: hidden; } aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid #30363d; overflow: hidden; }
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid #30363d; } header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid #30363d; }
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid #21262d; } project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid #21262d; }
session-list { flex: 1 1 auto; min-height: 0; overflow: auto; } session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.mobile-tabs { display: none; flex: 0 0 auto; gap: 6px; padding: 8px; border-bottom: 1px solid #30363d; }
.mobile-panel { display: none; }
.mobile-tabs button.selected { border-color: #58a6ff; background: #0d2847; }
workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid #30363d; overflow: hidden; }
@media (max-width: 1180px) {
.shell { grid-template-columns: 340px minmax(0, 1fr); }
.shell > workspace-panel { display: none; }
.mobile-tabs { display: flex; }
main.files-view chat-view, main.files-view prompt-editor, main.files-view status-bar,
main.git-view chat-view, main.git-view prompt-editor, main.git-view status-bar,
main.files-view .empty, main.git-view .empty { display: none; }
main.chat-view .mobile-panel { display: none; }
.mobile-panel { flex: 1 1 auto; min-height: 0; display: flex; }
.mobile-panel workspace-panel { flex: 1 1 auto; border-left: 0; }
}
status-bar { flex: 0 0 auto; } status-bar { flex: 0 0 auto; }
chat-view { flex: 1 1 auto; min-height: 0; overflow: auto; } chat-view { flex: 1 1 auto; min-height: 0; overflow: auto; }
prompt-editor, chat-composer { flex: 0 0 auto; } prompt-editor, chat-composer { flex: 0 0 auto; }
@@ -39,6 +54,30 @@ export const appStyles = css`
.error { padding: 10px 16px; border-bottom: 1px solid #30363d; color: #ff7b72; } .error { padding: 10px 16px; border-bottom: 1px solid #30363d; color: #ff7b72; }
`; `;
export const workspacePanelStyles = css`
:host { display: flex; flex-direction: column; min-height: 0; color: #e6edf3; background: #0d1117; font: 13px system-ui, sans-serif; }
header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #30363d; }
.tabs { display: flex; gap: 6px; }
button { border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; }
button.selected { border-color: #58a6ff; background: #0d2847; }
small, .muted { color: #8b949e; }
header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #21262d; }
.toolbar button { margin-left: auto; }
.stale { border: 1px solid #6e5200; border-radius: 999px; color: #d29922; padding: 1px 6px; font-size: 12px; }
.split { flex: 1 1 auto; min-height: 0; display: grid; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); }
.list { min-height: 0; overflow: auto; border-bottom: 1px solid #30363d; padding: 6px; }
.row { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 4px; width: 100%; border: 0; border-radius: 5px; background: transparent; text-align: left; padding: 4px 6px 4px calc(6px + var(--depth, 0) * 14px); }
.row:hover, .row.selected { background: #0d2847; }
.row span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary { margin: 4px 6px 8px; color: #8b949e; }
.viewer { min-height: 0; overflow: auto; }
.viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid #21262d; background: #0d1117; }
.viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
p { margin: 10px; }
`;
export const listStyles = css` export const listStyles = css`
:host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } :host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; }
section { padding: 10px; } section { padding: 10px; }
@@ -12,7 +12,7 @@ export class WorkspaceController {
async selectProject(project: Project, target?: RouteTarget) { async selectProject(project: Project, target?: RouteTarget) {
this.sessions.clearActiveSession(); this.sessions.clearActiveSession();
this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], error: "" }); this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, gitStale: false, error: "" });
try { try {
const workspaces = await api.workspaces(project.id); const workspaces = await api.workspaces(project.id);
this.setState({ workspaces }); this.setState({ workspaces });
@@ -26,7 +26,7 @@ export class WorkspaceController {
async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) { async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) {
this.sessions.clearActiveSession(); this.sessions.clearActiveSession();
this.setState({ selectedWorkspace: workspace, sessions: [], error: "" }); this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, gitStale: false, error: "" });
try { try {
const sessions = await api.sessions(workspace.path); const sessions = await api.sessions(workspace.path);
this.setState({ sessions }); this.setState({ sessions });
+24
View File
@@ -2,6 +2,10 @@ export interface AppRoute {
projectId: string | undefined; projectId: string | undefined;
workspaceId: string | undefined; workspaceId: string | undefined;
sessionId: string | undefined; sessionId: string | undefined;
tool: "files" | "git" | undefined;
view: "chat" | "files" | "git" | undefined;
file: string | undefined;
diff: string | undefined;
} }
export function readRoute(): AppRoute { export function readRoute(): AppRoute {
@@ -10,6 +14,10 @@ export function readRoute(): AppRoute {
projectId: params.get("project") ?? undefined, projectId: params.get("project") ?? undefined,
workspaceId: params.get("workspace") ?? undefined, workspaceId: params.get("workspace") ?? undefined,
sessionId: params.get("session") ?? undefined, sessionId: params.get("session") ?? undefined,
tool: parseTool(params.get("tool")),
view: parseView(params.get("view")),
file: params.get("file") ?? undefined,
diff: params.get("diff") ?? undefined,
}; };
} }
@@ -18,10 +26,26 @@ export function writeRoute(route: AppRoute): void {
url.searchParams.delete("project"); url.searchParams.delete("project");
url.searchParams.delete("workspace"); url.searchParams.delete("workspace");
url.searchParams.delete("session"); url.searchParams.delete("session");
url.searchParams.delete("tool");
url.searchParams.delete("view");
url.searchParams.delete("file");
url.searchParams.delete("diff");
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId); if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId); if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId); if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
if (route.tool !== undefined) url.searchParams.set("tool", route.tool);
if (route.view !== undefined) url.searchParams.set("view", route.view);
if (route.file !== undefined && route.file !== "") url.searchParams.set("file", route.file);
if (route.diff !== undefined && route.diff !== "") url.searchParams.set("diff", route.diff);
const next = `${url.pathname}${url.search}${url.hash}`; const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current) window.history.pushState({}, "", url); if (next !== current) window.history.pushState({}, "", url);
} }
function parseTool(value: string | null): "files" | "git" | undefined {
return value === "files" || value === "git" ? value : undefined;
}
function parseView(value: string | null): "chat" | "files" | "git" | undefined {
return value === "chat" || value === "files" || value === "git" ? value : undefined;
}
+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 }); });
});
}
+25
View File
@@ -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) });
}
});
}
+4
View File
@@ -8,6 +8,8 @@ import { ProjectService } from "./projects/projectService.js";
import { WorkspaceService } from "./workspaces/workspaceService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js";
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js"; import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js"; import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
@@ -35,6 +37,8 @@ app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces"
}); });
registerSessionProxyRoutes(app); 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) => { 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" }); if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
+26
View File
@@ -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 };
}
+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 };
}