From ba8f74bee2090ce95031b80dc0f0a5ba9bc5e4fd Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 7 May 2026 23:27:38 +0200 Subject: [PATCH] Add workspace side panel with files and git --- src/client/src/api.ts | 103 ++++++++++++ src/client/src/appState.ts | 24 ++- src/client/src/components/PiWebApp.ts | 151 +++++++++++++++++- src/client/src/components/WorkspacePanel.ts | 123 ++++++++++++++ src/client/src/components/shared.ts | 41 ++++- .../src/controllers/workspaceController.ts | 4 +- src/client/src/route.ts | 24 +++ src/server/git/gitService.ts | 126 +++++++++++++++ src/server/gitRoutes.ts | 25 +++ src/server/index.ts | 4 + src/server/workspaceExplorerRoutes.ts | 26 +++ src/server/workspaces/fileContentService.ts | 61 +++++++ src/server/workspaces/fileTreeService.ts | 42 +++++ src/server/workspaces/pathSafety.ts | 35 ++++ src/server/workspaces/workspaceContext.ts | 16 ++ 15 files changed, 799 insertions(+), 6 deletions(-) create mode 100644 src/client/src/components/WorkspacePanel.ts create mode 100644 src/server/git/gitService.ts create mode 100644 src/server/gitRoutes.ts create mode 100644 src/server/workspaceExplorerRoutes.ts create mode 100644 src/server/workspaces/fileContentService.ts create mode 100644 src/server/workspaces/fileTreeService.ts create mode 100644 src/server/workspaces/pathSafety.ts create mode 100644 src/server/workspaces/workspaceContext.ts diff --git a/src/client/src/api.ts b/src/client/src/api.ts index dc9cb4f..eb13dda 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"); diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 219dcb9..3f59924 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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; sessionActivities: Record; commandDialog: Extract | undefined; + workspaceTool: "files" | "git"; + mainView: "chat" | "files" | "git"; + fileTree: FileTreeEntry[]; + expandedDirs: Record; + 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: "", }; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index ebcddb1..83bb635 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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) { + 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) { @@ -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` 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)}>`; + } + override render() { const state = this.state; return html` @@ -111,7 +247,12 @@ export class PiWebApp extends LitElement { this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}> 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)}> -
+
+
+ + + +
${state.error ? html`
${state.error}
` : null} ${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> @@ -119,7 +260,9 @@ export class PiWebApp extends LitElement { ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ` : html`
Select or start a session.
`} +
${this.renderWorkspacePanel()}
+ ${this.renderWorkspacePanel()} `; } @@ -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 { return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); } diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts new file mode 100644 index 0000000..2c4c339 --- /dev/null +++ b/src/client/src/components/WorkspacePanel.ts @@ -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 = {}; + @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`
Select a workspace.
`; + return html` +
+
+ + +
+ ${this.workspace.label} +
+ ${this.tool === "files" ? this.renderFiles() : this.renderGit()} + `; + } + + private renderFiles() { + return html` +
+ Files + ${this.fileTreeStale ? html`stale` : null} + +
+
+
+ ${this.fileTree.length === 0 ? html`

No files loaded.

` : this.fileTree.map((entry) => this.renderTreeEntry(entry, 0))} +
+
+ ${this.renderFileViewer()} +
+
+ `; + } + + private renderTreeEntry(entry: FileTreeEntry, depth: number): TemplateResult { + const children = this.expandedDirs[entry.path]; + return html` + + ${children ? children.map((child) => this.renderTreeEntry(child, depth + 1)) : null} + `; + } + + private renderFileViewer() { + const file = this.selectedFileContent; + if (!this.selectedFilePath) return html`

Select a file.

`; + if (!file) return html`

Loading ${this.selectedFilePath}…

`; + if (file.binary) return html`

Binary file: ${file.path}

`; + return html` +
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
+
${file.content}
+ `; + } + + private renderGit() { + const status = this.gitStatus; + return html` +
+ Git + ${this.gitStale ? html`stale` : null} + +
+
+
+ ${status === undefined ? html`

No status loaded.

` : status.isGitRepo === false ? html`

Not a git repository.

` : html` +

${status.branch ?? "detached"}${status.ahead || status.behind ? ` · ↑${status.ahead ?? 0} ↓${status.behind ?? 0}` : ""}

+ ${status.files.length === 0 ? html`

No changes.

` : status.files.map((file) => html` + + `)} + `} +
+
+ ${this.renderDiffViewer()} +
+
+ `; + } + + private renderDiffViewer() { + if (!this.selectedDiffPath) return html`

Select a changed file.

`; + const diff = this.selectedDiff; + if (!diff) return html`

Loading diff…

`; + return html` +
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
+
${diff.diff || "No unstaged diff."}
+ `; + } + + static override styles = workspacePanelStyles; +} + +function stateLabel(index: string, workingTree: string): string { + const label = workingTree !== "unmodified" ? workingTree : index; + return label.slice(0, 1).toUpperCase(); +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 68465e1..bbb1d5b 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 27e39b9..688324c 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -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 }); diff --git a/src/client/src/route.ts b/src/client/src/route.ts index 2e1a12a..aa4213e 100644 --- a/src/client/src/route.ts +++ b/src/client/src/route.ts @@ -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; +} diff --git a/src/server/git/gitService.ts b/src/server/git/gitService.ts new file mode 100644 index 0000000..61bcbb1 --- /dev/null +++ b/src/server/git/gitService.ts @@ -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 { + 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 { + 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 }); }); + }); +} diff --git a/src/server/gitRoutes.ts b/src/server/gitRoutes.ts new file mode 100644 index 0000000..0246265 --- /dev/null +++ b/src/server/gitRoutes.ts @@ -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) }); + } + }); +} diff --git a/src/server/index.ts b/src/server/index.ts index 4e2b7bf..d940c74 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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" }); diff --git a/src/server/workspaceExplorerRoutes.ts b/src/server/workspaceExplorerRoutes.ts new file mode 100644 index 0000000..ff62613 --- /dev/null +++ b/src/server/workspaceExplorerRoutes.ts @@ -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) }); + } + }); +} diff --git a/src/server/workspaces/fileContentService.ts b/src/server/workspaces/fileContentService.ts new file mode 100644 index 0000000..4208c47 --- /dev/null +++ b/src/server/workspaces/fileContentService.ts @@ -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 { + 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)[ext]; + return language === undefined ? {} : { language }; +} diff --git a/src/server/workspaces/fileTreeService.ts b/src/server/workspaces/fileTreeService.ts new file mode 100644 index 0000000..b12cbe5 --- /dev/null +++ b/src/server/workspaces/fileTreeService.ts @@ -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 { + 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 => { + 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 }; +} diff --git a/src/server/workspaces/pathSafety.ts b/src/server/workspaces/pathSafety.ts new file mode 100644 index 0000000..c6820a2 --- /dev/null +++ b/src/server/workspaces/pathSafety.ts @@ -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"); +} diff --git a/src/server/workspaces/workspaceContext.ts b/src/server/workspaces/workspaceContext.ts new file mode 100644 index 0000000..ebcab1a --- /dev/null +++ b/src/server/workspaces/workspaceContext.ts @@ -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 { + 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 }; +}