refactor: extract workspace tool controllers

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 23:48:37 +02:00
parent 789be10147
commit 0338ba532b
4 changed files with 137 additions and 106 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# 04. Extract file/git controllers from PiWebApp # 04. Extract file/git controllers from PiWebApp
Status: pending Status: completed
Move file explorer and git orchestration out of `PiWebApp` into focused controllers. Move file explorer and git orchestration out of `PiWebApp` into focused controllers.
+31 -105
View File
@@ -1,7 +1,9 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
import { api, type Project, type SessionInfo, type Workspace } from "../api"; import type { Project, SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { ProjectController } from "../controllers/projectController"; import { ProjectController } from "../controllers/projectController";
import { SessionController } from "../controllers/sessionController"; import { SessionController } from "../controllers/sessionController";
import { WorkspaceController } from "../controllers/workspaceController"; import { WorkspaceController } from "../controllers/workspaceController";
@@ -40,8 +42,17 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); }, (patch) => { this.setState(patch); },
this.workspaces, this.workspaces,
); );
private readonly files = new FileExplorerController(
() => this.state,
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
);
private readonly git = new GitController(
() => this.state,
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
);
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private gitPollTimer: number | undefined;
override connectedCallback(): void { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
@@ -53,7 +64,7 @@ export class PiWebApp extends LitElement {
override disconnectedCallback(): void { override disconnectedCallback(): void {
window.removeEventListener("popstate", this.onPopState); window.removeEventListener("popstate", this.onPopState);
this.sessions.dispose(); this.sessions.dispose();
if (this.gitPollTimer !== undefined) window.clearInterval(this.gitPollTimer); this.git.dispose();
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -76,11 +87,11 @@ export class PiWebApp extends LitElement {
const project = this.state.projects.find((p) => p.id === route.projectId); const project = this.state.projects.find((p) => p.id === route.projectId);
if (!project) return; if (!project) return;
await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl });
if (route.tool === "files") await this.refreshFiles(); if (route.tool === "files") await this.files.refreshFiles();
if (route.file !== undefined) await this.selectFile(route.file); if (route.file !== undefined) await this.files.selectFile(route.file);
if (route.tool === "git") await this.refreshGit(); if (route.tool === "git") await this.git.refreshGit();
if (route.diff !== undefined) await this.selectDiff(route.diff); if (route.diff !== undefined) await this.git.selectDiff(route.diff);
this.updateGitPolling(); this.git.updatePolling();
} }
private async withChatScrollTransition(action: () => Promise<void>) { private async withChatScrollTransition(action: () => Promise<void>) {
@@ -117,97 +128,24 @@ export class PiWebApp extends LitElement {
private selectWorkspaceTool(tool: "files" | "git") { private selectWorkspaceTool(tool: "files" | "git") {
this.setState({ workspaceTool: tool, mainView: tool }); this.setState({ workspaceTool: tool, mainView: tool });
this.updateUrl(); this.updateUrl();
if (tool === "files") void this.refreshFiles(); if (tool === "files") void this.files.refreshFiles();
else void this.refreshGit(); else void this.git.refreshGit();
this.updateGitPolling(); this.git.updatePolling();
} }
private selectMainView(view: "chat" | "files" | "git") { private selectMainView(view: "chat" | "files" | "git") {
this.setState({ mainView: view, workspaceTool: view === "chat" ? this.state.workspaceTool : view }); this.setState({ mainView: view, workspaceTool: view === "chat" ? this.state.workspaceTool : view });
this.updateUrl(); this.updateUrl();
if (view === "files") void this.refreshFiles(); if (view === "files") void this.files.refreshFiles();
if (view === "git") void this.refreshGit(); if (view === "git") void this.git.refreshGit();
this.updateGitPolling(); this.git.updatePolling();
}
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) {
this.setState({ expandedDirs: omitKey(this.state.expandedDirs, path) });
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) { private handleWorkspaceChange(previous: AppState, next: AppState) {
if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id || next.selectedWorkspace === undefined) return; if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id || next.selectedWorkspace === undefined) return;
if (next.workspaceTool === "files") void this.refreshFiles(); if (next.workspaceTool === "files") void this.files.refreshFiles();
if (next.workspaceTool === "git") void this.refreshGit(); if (next.workspaceTool === "git") void this.git.refreshGit();
this.updateGitPolling(); this.git.updatePolling();
} }
private handleActivityTransition(previous: AppState, next: AppState) { private handleActivityTransition(previous: AppState, next: AppState) {
@@ -215,21 +153,13 @@ export class PiWebApp extends LitElement {
const nowActive = isActive(next.status); const nowActive = isActive(next.status);
if (wasActive && !nowActive) { if (wasActive && !nowActive) {
this.setState({ fileTreeStale: true, gitStale: true }); this.setState({ fileTreeStale: true, gitStale: true });
if (this.state.workspaceTool === "files") void this.refreshFiles(); if (this.state.workspaceTool === "files") void this.files.refreshFiles();
if (this.state.workspaceTool === "git") void this.refreshGit(); if (this.state.workspaceTool === "git") void this.git.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() { 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>`; 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.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
} }
override render() { override render() {
@@ -272,10 +202,6 @@ function isActive(status: AppState["status"]): boolean {
return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true; return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true;
} }
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit));
}
function nextFrame(): Promise<void> { function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
} }
@@ -0,0 +1,53 @@
import { api } from "../api";
import type { GetState, SetState, UpdateUrl } from "./types";
export class FileExplorerController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
async refreshFiles(): Promise<void> {
const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
try {
const root = await api.workspaceTree(project.id, workspace.id);
const expanded = { ...this.getState().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) });
}
}
async expandDir(path: string): Promise<void> {
const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
if (this.getState().expandedDirs[path] !== undefined) {
this.setState({ expandedDirs: omitKey(this.getState().expandedDirs, path) });
return;
}
try {
const response = await api.workspaceTree(project.id, workspace.id, path);
this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" });
} catch (error) {
this.setState({ error: String(error) });
}
}
async selectFile(path: string): Promise<void> {
const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
this.setState({ selectedFilePath: path, selectedFileContent: undefined, workspaceTool: "files", mainView: this.getState().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) });
}
}
}
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit));
}
@@ -0,0 +1,52 @@
import { api } from "../api";
import type { GetState, SetState, UpdateUrl } from "./types";
export class GitController {
private pollTimer: number | undefined;
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {}
dispose(): void {
if (this.pollTimer !== undefined) window.clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
async refreshGit(): Promise<void> {
const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
try {
const status = await api.gitStatus(project.id, workspace.id);
this.setState({ gitStatus: status, gitStale: false, error: "" });
const selectedDiffPath = this.getState().selectedDiffPath;
if (selectedDiffPath !== undefined && status.files.some((file) => file.path === selectedDiffPath)) await this.refreshDiff(selectedDiffPath);
} catch (error) {
this.setState({ error: String(error) });
}
}
async selectDiff(path: string): Promise<void> {
this.setState({ selectedDiffPath: path, selectedDiff: undefined, workspaceTool: "git", mainView: this.getState().mainView === "chat" ? "chat" : "git" });
this.updateUrl();
await this.refreshDiff(path);
}
async refreshDiff(path: string): Promise<void> {
const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
try {
this.setState({ selectedDiff: await api.gitDiff(project.id, workspace.id, { path }), error: "" });
} catch (error) {
this.setState({ error: String(error) });
}
}
updatePolling(): void {
this.dispose();
const state = this.getState();
if (state.workspaceTool === "git" || state.mainView === "git") {
this.pollTimer = window.setInterval(() => { void this.refreshGit(); }, 8000);
}
}
}