Archived
refactor: extract workspace tool controllers
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user