Add workspace side panel with files and git

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 23:27:38 +02:00
parent 34994705e4
commit ba8f74bee2
15 changed files with 799 additions and 6 deletions
+103
View File
@@ -60,6 +60,59 @@ export interface FileSuggestion {
kind: "tracked" | "untracked" | "other";
}
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");
+23 -1
View File
@@ -1,4 +1,4 @@
import type { CommandResult, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared";
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: "",
};
}
+149 -2
View File
@@ -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(); }));
}
+123
View File
@@ -0,0 +1,123 @@
import { LitElement, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
import { workspacePanelStyles } from "./shared";
@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@property() tool: "files" | "git" = "files";
@property({ attribute: false }) fileTree: FileTreeEntry[] = [];
@property({ attribute: false }) expandedDirs: Record<string, FileTreeEntry[]> = {};
@property({ attribute: false }) selectedFilePath: string | undefined;
@property({ attribute: false }) selectedFileContent: FileContentResponse | undefined;
@property({ type: Boolean }) fileTreeStale = false;
@property({ attribute: false }) gitStatus: GitStatusResponse | undefined;
@property({ attribute: false }) selectedDiffPath: string | undefined;
@property({ attribute: false }) selectedDiff: GitDiffResponse | undefined;
@property({ type: Boolean }) gitStale = false;
@property({ attribute: false }) onSelectTool: (tool: "files" | "git") => void = () => undefined;
@property({ attribute: false }) onRefreshFiles: () => void = () => undefined;
@property({ attribute: false }) onExpandDir: (path: string) => void = () => undefined;
@property({ attribute: false }) onSelectFile: (path: string) => void = () => undefined;
@property({ attribute: false }) onRefreshGit: () => void = () => undefined;
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
override render() {
if (!this.workspace) return html`<section class="empty">Select a workspace.</section>`;
return html`
<header>
<div class="tabs">
<button class=${this.tool === "files" ? "selected" : ""} @click=${() => this.onSelectTool("files")}>Files</button>
<button class=${this.tool === "git" ? "selected" : ""} @click=${() => this.onSelectTool("git")}>Git</button>
</div>
<small title=${this.workspace.path}>${this.workspace.label}</small>
</header>
${this.tool === "files" ? this.renderFiles() : this.renderGit()}
`;
}
private renderFiles() {
return html`
<section class="toolbar">
<strong>Files</strong>
${this.fileTreeStale ? html`<span class="stale">stale</span>` : null}
<button @click=${this.onRefreshFiles}>Refresh</button>
</section>
<section class="split">
<div class="list tree">
${this.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : this.fileTree.map((entry) => this.renderTreeEntry(entry, 0))}
</div>
<div class="viewer">
${this.renderFileViewer()}
</div>
</section>
`;
}
private renderTreeEntry(entry: FileTreeEntry, depth: number): TemplateResult {
const children = this.expandedDirs[entry.path];
return html`
<button class="row" style=${`--depth:${depth}`} @click=${() => entry.type === "directory" ? this.onExpandDir(entry.path) : this.onSelectFile(entry.path)}>
<span>${entry.type === "directory" ? (children ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${children ? children.map((child) => this.renderTreeEntry(child, depth + 1)) : null}
`;
}
private renderFileViewer() {
const file = this.selectedFileContent;
if (!this.selectedFilePath) return html`<p class="muted">Select a file.</p>`;
if (!file) return html`<p class="muted">Loading ${this.selectedFilePath}…</p>`;
if (file.binary) return html`<p class="muted">Binary file: ${file.path}</p>`;
return html`
<div class="viewer-header"><strong>${file.path}</strong><small>${file.language ?? "text"}${file.truncated ? " · truncated" : ""}</small></div>
<pre><code>${file.content}</code></pre>
`;
}
private renderGit() {
const status = this.gitStatus;
return html`
<section class="toolbar">
<strong>Git</strong>
${this.gitStale ? html`<span class="stale">stale</span>` : null}
<button @click=${this.onRefreshGit}>Refresh</button>
</section>
<section class="split">
<div class="list">
${status === undefined ? html`<p class="muted">No status loaded.</p>` : status.isGitRepo === false ? html`<p class="muted">Not a git repository.</p>` : html`
<p class="summary">${status.branch ?? "detached"}${status.ahead || status.behind ? ` · ↑${status.ahead ?? 0}${status.behind ?? 0}` : ""}</p>
${status.files.length === 0 ? html`<p class="muted">No changes.</p>` : status.files.map((file) => html`
<button class="row ${this.selectedDiffPath === file.path ? "selected" : ""}" @click=${() => this.onSelectDiff(file.path)}>
<span>${stateLabel(file.index, file.workingTree)}</span>
<span>${file.path}</span>
</button>
`)}
`}
</div>
<div class="viewer">
${this.renderDiffViewer()}
</div>
</section>
`;
}
private renderDiffViewer() {
if (!this.selectedDiffPath) return html`<p class="muted">Select a changed file.</p>`;
const diff = this.selectedDiff;
if (!diff) return html`<p class="muted">Loading diff…</p>`;
return html`
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<pre><code>${diff.diff || "No unstaged diff."}</code></pre>
`;
}
static override styles = workspacePanelStyles;
}
function stateLabel(index: string, workingTree: string): string {
const label = workingTree !== "unmodified" ? workingTree : index;
return label.slice(0, 1).toUpperCase();
}
+40 -1
View File
@@ -25,12 +25,27 @@ export interface CompletionItem {
export const appStyles = css`
: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 });
+24
View File
@@ -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;
}