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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user