Archived
Add workspace side panel with files and git
This commit is contained in:
@@ -60,6 +60,59 @@ export interface FileSuggestion {
|
||||
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 {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -104,6 +157,10 @@ export const api = {
|
||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
||||
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)),
|
||||
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 }) }),
|
||||
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 }) }),
|
||||
@@ -122,6 +179,14 @@ export function globalSessionEvents(): WebSocket {
|
||||
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 {
|
||||
const params = new URLSearchParams();
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
const record = requireRecord(value);
|
||||
const type = requireString(record, "type");
|
||||
|
||||
@@ -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";
|
||||
|
||||
export interface AppState {
|
||||
@@ -17,6 +17,17 @@ export interface AppState {
|
||||
sessionStatuses: Record<string, SessionStatus>;
|
||||
sessionActivities: Record<string, SessionActivity>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,6 +48,17 @@ export function initialAppState(): AppState {
|
||||
sessionStatuses: {},
|
||||
sessionActivities: {},
|
||||
commandDialog: undefined,
|
||||
workspaceTool: "files",
|
||||
mainView: "chat",
|
||||
fileTree: [],
|
||||
expandedDirs: {},
|
||||
selectedFilePath: undefined,
|
||||
selectedFileContent: undefined,
|
||||
fileTreeStale: false,
|
||||
gitStatus: undefined,
|
||||
selectedDiffPath: undefined,
|
||||
selectedDiff: undefined,
|
||||
gitStale: false,
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
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 { ProjectController } from "../controllers/projectController";
|
||||
import { SessionController } from "../controllers/sessionController";
|
||||
@@ -15,6 +15,7 @@ import "./PromptEditor";
|
||||
import type { PromptEditor } from "./PromptEditor";
|
||||
import "./StatusBar";
|
||||
import "./CommandPicker";
|
||||
import "./WorkspacePanel";
|
||||
import { appStyles } from "./shared";
|
||||
|
||||
@customElement("pi-web-poc")
|
||||
@@ -40,6 +41,7 @@ export class PiWebApp extends LitElement {
|
||||
this.workspaces,
|
||||
);
|
||||
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||
private gitPollTimer: number | undefined;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -51,11 +53,15 @@ export class PiWebApp extends LitElement {
|
||||
override disconnectedCallback(): void {
|
||||
window.removeEventListener("popstate", this.onPopState);
|
||||
this.sessions.dispose();
|
||||
if (this.gitPollTimer !== undefined) window.clearInterval(this.gitPollTimer);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private setState(patch: Partial<AppState>) {
|
||||
const previous = this.state;
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.handleActivityTransition(previous, this.state);
|
||||
this.handleWorkspaceChange(previous, this.state);
|
||||
}
|
||||
|
||||
private async loadProjectsAndRestoreRoute() {
|
||||
@@ -65,10 +71,16 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private async restoreRoute(updateUrl: boolean) {
|
||||
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;
|
||||
const project = this.state.projects.find((p) => p.id === route.projectId);
|
||||
if (!project) return;
|
||||
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>) {
|
||||
@@ -95,9 +107,133 @@ export class PiWebApp extends LitElement {
|
||||
projectId: this.state.selectedProject?.id,
|
||||
workspaceId: this.state.selectedWorkspace?.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() {
|
||||
const state = this.state;
|
||||
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>
|
||||
<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>
|
||||
<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.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>
|
||||
@@ -119,7 +260,9 @@ export class PiWebApp extends LitElement {
|
||||
<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}
|
||||
` : html`<div class="empty">Select or start a session.</div>`}
|
||||
<div class="mobile-panel">${this.renderWorkspacePanel()}</div>
|
||||
</main>
|
||||
${this.renderWorkspacePanel()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -127,6 +270,10 @@ export class PiWebApp extends LitElement {
|
||||
static override styles = appStyles;
|
||||
}
|
||||
|
||||
function isActive(status: AppState["status"]): boolean {
|
||||
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -25,12 +25,27 @@ export interface CompletionItem {
|
||||
|
||||
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; }
|
||||
.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; }
|
||||
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; }
|
||||
session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
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; }
|
||||
chat-view { flex: 1 1 auto; min-height: 0; overflow: 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; }
|
||||
`;
|
||||
|
||||
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`
|
||||
:host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
||||
section { padding: 10px; }
|
||||
|
||||
@@ -12,7 +12,7 @@ export class WorkspaceController {
|
||||
|
||||
async selectProject(project: Project, target?: RouteTarget) {
|
||||
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 {
|
||||
const workspaces = await api.workspaces(project.id);
|
||||
this.setState({ workspaces });
|
||||
@@ -26,7 +26,7 @@ export class WorkspaceController {
|
||||
|
||||
async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) {
|
||||
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 {
|
||||
const sessions = await api.sessions(workspace.path);
|
||||
this.setState({ sessions });
|
||||
|
||||
@@ -2,6 +2,10 @@ export interface AppRoute {
|
||||
projectId: string | undefined;
|
||||
workspaceId: string | undefined;
|
||||
sessionId: string | undefined;
|
||||
tool: "files" | "git" | undefined;
|
||||
view: "chat" | "files" | "git" | undefined;
|
||||
file: string | undefined;
|
||||
diff: string | undefined;
|
||||
}
|
||||
|
||||
export function readRoute(): AppRoute {
|
||||
@@ -10,6 +14,10 @@ export function readRoute(): AppRoute {
|
||||
projectId: params.get("project") ?? undefined,
|
||||
workspaceId: params.get("workspace") ?? 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("workspace");
|
||||
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.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
|
||||
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 current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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