diff --git a/src/client/src/appActions.test.ts b/src/client/src/appActions.test.ts deleted file mode 100644 index 53caf64..0000000 --- a/src/client/src/appActions.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createAppActions } from "./appActions"; -import { initialAppState, type AppState } from "./appState"; -import type { SessionInfo } from "./api"; - -function createContext(statePatch: Partial = {}) { - const calls: string[] = []; - const context = { - state: { ...initialAppState(), ...statePatch }, - openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }), - focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }), - addProject: vi.fn(() => { calls.push("addProject"); }), - selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }), - refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }), - refreshGit: vi.fn(() => { calls.push("refreshGit"); }), - startSession: vi.fn(() => { calls.push("startSession"); }), - archiveSession: vi.fn(() => { calls.push("archiveSession"); }), - stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }), - }; - return { context, calls }; -} - -describe("createAppActions", () => { - it("disables workspace and session actions when no workspace/session is selected", () => { - const { context } = createContext(); - const actions = createAppActions(context); - - expect(actions.find((action) => action.id === "view.files")?.enabled).toBe(false); - expect(actions.find((action) => action.id === "session.start")?.enabled).toBe(false); - expect(actions.find((action) => action.id === "session.archive")?.enabled).toBe(false); - expect(actions.find((action) => action.id === "session.stop")?.enabled).toBe(false); - expect(actions.find((action) => action.id === "actions.show")?.enabled).toBeUndefined(); - }); - - it("enables workspace actions when a workspace is selected", () => { - const { context } = createContext({ selectedWorkspace: testWorkspace() }); - const actions = createAppActions(context); - - expect(actions.find((action) => action.id === "view.files")?.enabled).toBe(true); - expect(actions.find((action) => action.id === "session.start")?.enabled).toBe(true); - }); - - it("routes refresh current to the active workspace tool", () => { - const { context, calls } = createContext({ - selectedWorkspace: testWorkspace(), - workspaceTool: "git", - }); - const action = createAppActions(context).find((candidate) => candidate.id === "workspace.refresh-current"); - - if (action !== undefined) void action.run(); - - expect(calls).toEqual(["refreshGit"]); - }); - - it("enables archive for the selected active session", () => { - const selectedSession = testSession(); - const active = createAppActions(createContext({ selectedSession }).context); - const archived = createAppActions(createContext({ selectedSession: { ...selectedSession, archived: true } }).context); - - expect(active.find((action) => action.id === "session.archive")?.enabled).toBe(true); - expect(archived.find((action) => action.id === "session.archive")?.enabled).toBe(false); - }); - - it("runs archive on the selected session", () => { - const { context, calls } = createContext({ selectedSession: testSession() }); - const action = createAppActions(context).find((candidate) => candidate.id === "session.archive"); - - if (action !== undefined) void action.run(); - - expect(calls).toEqual(["archiveSession"]); - }); - - it("only enables stop while a session is actively working", () => { - const selectedSession = testSession(); - const inactive = createAppActions(createContext({ selectedSession }).context); - const active = createAppActions(createContext({ selectedSession, status: testStatus({ isStreaming: true }) }).context); - - expect(inactive.find((action) => action.id === "session.stop")?.enabled).toBe(false); - expect(active.find((action) => action.id === "session.stop")?.enabled).toBe(true); - }); -}); - -function testWorkspace(): AppState["selectedWorkspace"] { - return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitWorktree: false }; -} - -function testSession(): SessionInfo { - return { id: "s1", path: "/tmp/project/.pi/sessions/s1", cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" }; -} - -function testStatus(patch: Partial> = {}): AppState["status"] { - return { - sessionId: "s1", - isStreaming: false, - isCompacting: false, - isBashRunning: false, - pendingMessageCount: 0, - tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - cost: 0, - ...patch, - }; -} diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index d1df640..4052f04 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -1,5 +1,6 @@ import type { CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api"; import type { ChatLine } from "./components/shared"; +import type { QualifiedContributionId } from "./plugins/types"; export interface AppState { projects: Project[]; @@ -19,8 +20,8 @@ export interface AppState { commandDialog: Extract | undefined; actionPaletteOpen: boolean; projectDialogOpen: boolean; - workspaceTool: "files" | "git"; - mainView: "chat" | "files" | "git"; + workspaceTool: QualifiedContributionId; + mainView: "chat" | QualifiedContributionId; fileTree: FileTreeEntry[]; expandedDirs: Record; selectedFilePath: string | undefined; @@ -53,7 +54,7 @@ export function initialAppState(): AppState { commandDialog: undefined, actionPaletteOpen: false, projectDialogOpen: false, - workspaceTool: "files", + workspaceTool: "core:workspace.files", mainView: "chat", fileTree: [], expandedDirs: {}, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index f6d1b58..35b8fed 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -2,7 +2,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import type { Project, SessionInfo, Workspace } from "../api"; import type { AppAction } from "../actions"; -import { createAppActions } from "../appActions"; import { initialAppState, type AppState } from "../appState"; import { FileExplorerController } from "../controllers/fileExplorerController"; import { GitController } from "../controllers/gitController"; @@ -10,6 +9,10 @@ import { ProjectController } from "../controllers/projectController"; import { SessionController } from "../controllers/sessionController"; import { WorkspaceController } from "../controllers/workspaceController"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; +import type { QualifiedContributionId, PluginRuntimeContext } from "../plugins/types"; +import { corePlugin } from "../plugins/core"; +import { examplePlugin } from "../plugins/example"; +import { PluginRegistry } from "../plugins/registry"; import { readRoute, writeRoute } from "../route"; import "./ProjectList"; import "./WorkspaceList"; @@ -58,6 +61,7 @@ export class PiWebApp extends LitElement { () => { this.updateUrl(); }, ); private readonly keyboard = new KeyboardShortcutDispatcher(); + private readonly plugins = createPluginRegistry(); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onKeyDown = (event: KeyboardEvent) => { if (this.keyboard.handle(event, this.getActions())) { @@ -102,9 +106,9 @@ export class PiWebApp extends LitElement { 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.files.refreshFiles(); + if (route.tool === "core:workspace.files") await this.files.refreshFiles(); if (route.file !== undefined) await this.files.selectFile(route.file); - if (route.tool === "git") await this.git.refreshGit(); + if (route.tool === "core:workspace.git") await this.git.refreshGit(); if (route.diff !== undefined) await this.git.selectDiff(route.diff); this.git.updatePolling(); } @@ -140,26 +144,23 @@ export class PiWebApp extends LitElement { }); } - private selectWorkspaceTool(tool: "files" | "git") { + private selectWorkspaceTool(tool: QualifiedContributionId) { this.setState({ workspaceTool: tool, mainView: tool }); this.updateUrl(); - if (tool === "files") void this.files.refreshFiles(); - else void this.git.refreshGit(); + this.refreshSelectedWorkspaceTool(tool); this.git.updatePolling(); } - private selectMainView(view: "chat" | "files" | "git") { + private selectMainView(view: AppState["mainView"]) { this.setState({ mainView: view, workspaceTool: view === "chat" ? this.state.workspaceTool : view }); this.updateUrl(); - if (view === "files") void this.files.refreshFiles(); - if (view === "git") void this.git.refreshGit(); + if (view !== "chat") this.refreshSelectedWorkspaceTool(view); this.git.updatePolling(); } private handleWorkspaceChange(previous: AppState, next: AppState) { if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id || next.selectedWorkspace === undefined) return; - if (next.workspaceTool === "files") void this.files.refreshFiles(); - if (next.workspaceTool === "git") void this.git.refreshGit(); + this.refreshSelectedWorkspaceTool(next.workspaceTool); this.git.updatePolling(); } @@ -168,28 +169,37 @@ export class PiWebApp extends LitElement { const nowActive = isActive(next.status); if (wasActive && !nowActive) { this.setState({ fileTreeStale: true, gitStale: true }); - if (this.state.workspaceTool === "files") void this.files.refreshFiles(); - if (this.state.workspaceTool === "git") void this.git.refreshGit(); + this.refreshSelectedWorkspaceTool(this.state.workspaceTool); } } + private refreshSelectedWorkspaceTool(tool: QualifiedContributionId): void { + if (tool === "core:workspace.files") void this.files.refreshFiles(); + if (tool === "core:workspace.git") void this.git.refreshGit(); + } + private renderWorkspacePanel() { - return html` { 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)}>`; + return html` { 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)}>`; } private getActions(): AppAction[] { - return createAppActions({ + return this.plugins.getActions(this.createPluginRuntimeContext()); + } + + private createPluginRuntimeContext(): PluginRuntimeContext { + return { state: this.state, openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, focusPrompt: () => { this.promptEditor?.focusInput(); }, addProject: () => { this.setState({ projectDialogOpen: true }); }, selectMainView: (view) => { this.selectMainView(view); }, + selectWorkspaceTool: (tool) => { this.selectWorkspaceTool(tool); }, refreshFiles: () => this.files.refreshFiles(), refreshGit: () => this.git.refreshGit(), startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()), archiveSession: () => this.sessions.archiveSession(), stopActiveWork: () => this.sessions.stopActiveWork(), - }); + }; } private runAction(actionId: string) { @@ -210,11 +220,12 @@ export class PiWebApp extends LitElement { this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}> this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => this.sessions.restoreSession(session)}> -
+
- - + ${this.plugins.getWorkspacePanels().map((panel) => html` + + `)}
${state.error ? html`
${state.error}
` : null} ${state.selectedSession ? html` @@ -235,6 +246,13 @@ export class PiWebApp extends LitElement { static override styles = appStyles; } +function createPluginRegistry(): PluginRegistry { + const registry = new PluginRegistry(); + registry.register(corePlugin); + registry.register(examplePlugin); + return registry; +} + function isActive(status: AppState["status"]): boolean { return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true; } diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 86250c2..030cdd7 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -1,13 +1,14 @@ -import { LitElement, html, type TemplateResult } from "lit"; +import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api"; +import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspacePanelContext } from "../plugins/types"; import { workspacePanelStyles } from "./shared"; -import "./CodeViewer"; @customElement("workspace-panel") export class WorkspacePanel extends LitElement { @property({ attribute: false }) workspace: Workspace | undefined; - @property() tool: "files" | "git" = "files"; + @property() tool: QualifiedContributionId = "core:workspace.files"; + @property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = []; @property({ attribute: false }) fileTree: FileTreeEntry[] = []; @property({ attribute: false }) expandedDirs: Record = {}; @property({ attribute: false }) selectedFilePath: string | undefined; @@ -18,7 +19,7 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) selectedDiff: GitDiffResponse | undefined; @property({ attribute: false }) selectedStagedDiff: GitDiffResponse | undefined; @property({ type: Boolean }) gitStale = false; - @property({ attribute: false }) onSelectTool: (tool: "files" | "git") => void = () => undefined; + @property({ attribute: false }) onSelectTool: (tool: QualifiedContributionId) => void = () => undefined; @property({ attribute: false }) onRefreshFiles: () => void = () => undefined; @property({ attribute: false }) onExpandDir: (path: string) => void = () => undefined; @property({ attribute: false }) onSelectFile: (path: string) => void = () => undefined; @@ -27,125 +28,40 @@ export class WorkspacePanel extends LitElement { override render() { if (!this.workspace) return html`
Select a workspace.
`; + const selectedPanel = this.panels.find((panel) => panel.id === this.tool) ?? this.panels[0]; return html`
- - + ${this.panels.map((panel) => html` + + `)}
${this.workspace.label}
- ${this.tool === "files" ? this.renderFiles() : this.renderGit()} + ${selectedPanel === undefined ? html`
No workspace panels registered.
` : selectedPanel.render(this.createPanelContext(this.workspace))} `; } - private renderFiles() { - return html` -
- Files - ${this.fileTreeStale ? html`stale` : null} - -
-
-
- ${this.fileTree.length === 0 ? html`

No files loaded.

` : this.fileTree.map((entry) => this.renderTreeEntry(entry, 0))} -
-
- ${this.renderFileViewer()} -
-
- `; - } - - private renderTreeEntry(entry: FileTreeEntry, depth: number): TemplateResult { - const children = this.expandedDirs[entry.path]; - const hasChildren = children !== undefined; - return html` - - ${hasChildren ? children.map((child) => this.renderTreeEntry(child, depth + 1)) : null} - `; - } - - private selectTreeEntry(entry: FileTreeEntry): void { - if (entry.type === "directory") this.onExpandDir(entry.path); - else this.onSelectFile(entry.path); - } - - private renderFileViewer() { - const file = this.selectedFileContent; - if (this.selectedFilePath === undefined || this.selectedFilePath === "") return html`

Select a file.

`; - if (file === undefined) return html`

Loading ${this.selectedFilePath}…

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

Binary file: ${file.path}

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

No status loaded.

` : !status.isGitRepo ? html`

Not a git repository.

` : html` -

${this.gitSummary(status)}

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

No changes.

` : status.files.map((file) => html` - - `)} - `} -
-
- ${this.renderDiffViewer()} -
-
- `; - } - - private renderDiffViewer() { - if (this.selectedDiffPath === undefined || this.selectedDiffPath === "") return html`

Select a changed file.

`; - const unstaged = this.selectedDiff; - const staged = this.selectedStagedDiff; - if (unstaged === undefined || staged === undefined) return html`

Loading diff…

`; - const diffs = [staged, unstaged].filter((diff) => diff.diff !== ""); - if (diffs.length === 0) return html`

No staged or unstaged diff.

`; - return html` -
- ${diffs.map((diff) => this.renderDiffSection(diff))} -
- `; - } - - private renderDiffSection(diff: GitDiffResponse) { - return html` -
-
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
- -
- `; - } - - private gitSummary(status: GitStatusResponse): string { - const branch = status.branch ?? "detached"; - const ahead = status.ahead ?? 0; - const behind = status.behind ?? 0; - return ahead === 0 && behind === 0 ? branch : `${branch} · ↑${String(ahead)} ↓${String(behind)}`; + private createPanelContext(workspace: Workspace): WorkspacePanelContext { + return { + workspace, + fileTree: this.fileTree, + expandedDirs: this.expandedDirs, + selectedFilePath: this.selectedFilePath, + selectedFileContent: this.selectedFileContent, + fileTreeStale: this.fileTreeStale, + gitStatus: this.gitStatus, + selectedDiffPath: this.selectedDiffPath, + selectedDiff: this.selectedDiff, + selectedStagedDiff: this.selectedStagedDiff, + gitStale: this.gitStale, + onRefreshFiles: this.onRefreshFiles, + onExpandDir: this.onExpandDir, + onSelectFile: this.onSelectFile, + onRefreshGit: this.onRefreshGit, + onSelectDiff: this.onSelectDiff, + }; } static override styles = workspacePanelStyles; } - -function stateLabel(index: string, workingTree: string): string { - const label = workingTree !== "unmodified" ? workingTree : index; - return label.slice(0, 1).toUpperCase(); -} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index f779f4e..b7e04e0 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -40,9 +40,8 @@ export const appStyles = css` .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.workspace-view chat-view, main.workspace-view prompt-editor, main.workspace-view status-bar, + main.workspace-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; } diff --git a/src/client/src/controllers/fileExplorerController.ts b/src/client/src/controllers/fileExplorerController.ts index 555d902..5967233 100644 --- a/src/client/src/controllers/fileExplorerController.ts +++ b/src/client/src/controllers/fileExplorerController.ts @@ -38,7 +38,7 @@ export class FileExplorerController { 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.setState({ selectedFilePath: path, selectedFileContent: undefined, workspaceTool: "core:workspace.files", mainView: this.getState().mainView === "chat" ? "chat" : "core:workspace.files" }); this.updateUrl(); try { this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" }); diff --git a/src/client/src/controllers/gitController.ts b/src/client/src/controllers/gitController.ts index 708d51f..4c0712e 100644 --- a/src/client/src/controllers/gitController.ts +++ b/src/client/src/controllers/gitController.ts @@ -32,7 +32,7 @@ export class GitController { } async selectDiff(path: string): Promise { - this.setState({ selectedDiffPath: path, selectedDiff: undefined, selectedStagedDiff: undefined, workspaceTool: "git", mainView: this.getState().mainView === "chat" ? "chat" : "git" }); + this.setState({ selectedDiffPath: path, selectedDiff: undefined, selectedStagedDiff: undefined, workspaceTool: "core:workspace.git", mainView: this.getState().mainView === "chat" ? "chat" : "core:workspace.git" }); this.updateUrl(); await this.refreshDiff(path); } @@ -55,7 +55,7 @@ export class GitController { updatePolling(): void { this.dispose(); const state = this.getState(); - if (state.workspaceTool === "git" || state.mainView === "git") { + if (state.workspaceTool === "core:workspace.git" || state.mainView === "core:workspace.git") { this.pollTimer = window.setInterval(() => { void this.refreshGit(); }, 8000); } } diff --git a/src/client/src/appActions.ts b/src/client/src/plugins/core/actions.ts similarity index 56% rename from src/client/src/appActions.ts rename to src/client/src/plugins/core/actions.ts index 56aadd0..d550557 100644 --- a/src/client/src/appActions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -1,24 +1,7 @@ -import type { AppAction } from "./actions"; -import type { AppState } from "./appState"; +import type { AppState } from "../../appState"; +import type { PluginAction } from "../types"; -export interface AppActionContext { - state: AppState; - openActionPalette: () => void; - focusPrompt: () => void; - addProject: () => void | Promise; - selectMainView: (view: AppState["mainView"]) => void; - refreshFiles: () => void | Promise; - refreshGit: () => void | Promise; - startSession: () => void | Promise; - archiveSession: () => void | Promise; - stopActiveWork: () => void | Promise; -} - -export function createAppActions(context: AppActionContext): AppAction[] { - const hasWorkspace = context.state.selectedWorkspace !== undefined; - const hasSession = context.state.selectedSession !== undefined; - const canArchiveSession = hasSession && context.state.selectedSession?.archived !== true; - const isBusy = isActive(context.state.status); +export function createCoreActions(): PluginAction[] { return [ { id: "actions.show", @@ -26,28 +9,28 @@ export function createAppActions(context: AppActionContext): AppAction[] { description: "Open the command palette", shortcut: "mod+k", group: "General", - run: context.openActionPalette, + run: (context) => { context.openActionPalette(); }, }, { id: "prompt.focus", title: "Focus Prompt", description: "Move keyboard focus to the message composer", group: "General", - enabled: hasSession, - run: context.focusPrompt, + enabled: (context) => context.state.selectedSession !== undefined, + run: (context) => { context.focusPrompt(); }, }, { id: "project.add", title: "Add Project", group: "Project", - run: context.addProject, + run: (context) => context.addProject(), }, { id: "view.chat", title: "Go to Chat", shortcut: "mod+1", group: "Navigation", - run: () => { context.selectMainView("chat"); }, + run: (context) => { context.selectMainView("chat"); }, }, { id: "view.files", @@ -55,7 +38,7 @@ export function createAppActions(context: AppActionContext): AppAction[] { shortcut: "mod+2", group: "Navigation", enabled: hasWorkspace, - run: () => { context.selectMainView("files"); }, + run: (context) => { context.selectMainView("core:workspace.files"); }, }, { id: "view.git", @@ -63,7 +46,7 @@ export function createAppActions(context: AppActionContext): AppAction[] { shortcut: "mod+3", group: "Navigation", enabled: hasWorkspace, - run: () => { context.selectMainView("git"); }, + run: (context) => { context.selectMainView("core:workspace.git"); }, }, { id: "workspace.refresh-files", @@ -71,7 +54,7 @@ export function createAppActions(context: AppActionContext): AppAction[] { shortcut: "mod+shift+f", group: "Workspace", enabled: hasWorkspace, - run: context.refreshFiles, + run: (context) => context.refreshFiles(), }, { id: "workspace.refresh-git", @@ -79,7 +62,7 @@ export function createAppActions(context: AppActionContext): AppAction[] { shortcut: "mod+shift+g", group: "Workspace", enabled: hasWorkspace, - run: context.refreshGit, + run: (context) => context.refreshGit(), }, { id: "workspace.refresh-current", @@ -87,7 +70,7 @@ export function createAppActions(context: AppActionContext): AppAction[] { shortcut: "mod+shift+r", group: "Workspace", enabled: hasWorkspace, - run: () => context.state.workspaceTool === "git" ? context.refreshGit() : context.refreshFiles(), + run: (context) => context.state.workspaceTool === "core:workspace.git" ? context.refreshGit() : context.refreshFiles(), }, { id: "session.start", @@ -95,27 +78,31 @@ export function createAppActions(context: AppActionContext): AppAction[] { shortcut: "mod+enter", group: "Session", enabled: hasWorkspace, - run: context.startSession, + run: (context) => context.startSession(), }, { id: "session.archive", title: "Archive Session", description: "Archive the selected session", group: "Session", - enabled: canArchiveSession, - run: context.archiveSession, + enabled: (context) => context.state.selectedSession !== undefined && context.state.selectedSession.archived !== true, + run: (context) => context.archiveSession(), }, { id: "session.stop", title: "Stop Active Work", shortcut: "mod+.", group: "Session", - enabled: hasSession && isBusy, - run: context.stopActiveWork, + enabled: (context) => context.state.selectedSession !== undefined && isActive(context.state.status), + run: (context) => context.stopActiveWork(), }, ]; } +function hasWorkspace(context: { state: AppState }): boolean { + return context.state.selectedWorkspace !== undefined; +} + function isActive(status: AppState["status"]): boolean { return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true; } diff --git a/src/client/src/plugins/core/index.ts b/src/client/src/plugins/core/index.ts new file mode 100644 index 0000000..5fbdb47 --- /dev/null +++ b/src/client/src/plugins/core/index.ts @@ -0,0 +1,12 @@ +import type { PiWebPlugin } from "../types"; +import { createCoreActions } from "./actions"; +import { createCoreWorkspacePanels } from "./panels"; + +export const corePlugin: PiWebPlugin = { + id: "core", + name: "Pi Web Core", + activate: () => ({ + actions: createCoreActions(), + workspacePanels: createCoreWorkspacePanels(), + }), +}; diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts new file mode 100644 index 0000000..2e21f52 --- /dev/null +++ b/src/client/src/plugins/core/panels.ts @@ -0,0 +1,129 @@ +import { html, type TemplateResult } from "lit"; +import type { FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api"; +import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types"; +import "../../components/CodeViewer"; + +export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { + return [ + { + id: "workspace.files", + title: "Files", + order: 10, + render: renderFiles, + }, + { + id: "workspace.git", + title: "Git", + order: 20, + render: renderGit, + }, + ]; +} + +function renderFiles(context: WorkspacePanelContext): TemplateResult { + return html` +
+ Files + ${context.fileTreeStale ? html`stale` : null} + +
+
+
+ ${context.fileTree.length === 0 ? html`

No files loaded.

` : context.fileTree.map((entry) => renderTreeEntry(context, entry, 0))} +
+
+ ${renderFileViewer(context)} +
+
+ `; +} + +function renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult { + const children = context.expandedDirs[entry.path]; + const hasChildren = children !== undefined; + return html` + + ${hasChildren ? children.map((child) => renderTreeEntry(context, child, depth + 1)) : null} + `; +} + +function selectTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry): void { + if (entry.type === "directory") context.onExpandDir(entry.path); + else context.onSelectFile(entry.path); +} + +function renderFileViewer(context: WorkspacePanelContext): TemplateResult { + const file = context.selectedFileContent; + if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`

Select a file.

`; + if (file === undefined) return html`

Loading ${context.selectedFilePath}…

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

Binary file: ${file.path}

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

No status loaded.

` : !status.isGitRepo ? html`

Not a git repository.

` : html` +

${gitSummary(status)}

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

No changes.

` : status.files.map((file) => html` + + `)} + `} +
+
+ ${renderDiffViewer(context)} +
+
+ `; +} + +function renderDiffViewer(context: WorkspacePanelContext): TemplateResult { + if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`

Select a changed file.

`; + const unstaged = context.selectedDiff; + const staged = context.selectedStagedDiff; + if (unstaged === undefined || staged === undefined) return html`

Loading diff…

`; + const diffs = [staged, unstaged].filter((diff) => diff.diff !== ""); + if (diffs.length === 0) return html`

No staged or unstaged diff.

`; + return html` +
+ ${diffs.map((diff) => renderDiffSection(diff))} +
+ `; +} + +function renderDiffSection(diff: GitDiffResponse): TemplateResult { + return html` +
+
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
+ +
+ `; +} + +function gitSummary(status: GitStatusResponse): string { + const branch = status.branch ?? "detached"; + const ahead = status.ahead ?? 0; + const behind = status.behind ?? 0; + return ahead === 0 && behind === 0 ? branch : `${branch} · ↑${String(ahead)} ↓${String(behind)}`; +} + +function stateLabel(index: string, workingTree: string): string { + const label = workingTree !== "unmodified" ? workingTree : index; + return label.slice(0, 1).toUpperCase(); +} diff --git a/src/client/src/plugins/example/index.ts b/src/client/src/plugins/example/index.ts new file mode 100644 index 0000000..04f8030 --- /dev/null +++ b/src/client/src/plugins/example/index.ts @@ -0,0 +1,36 @@ +import { html } from "lit"; +import type { PiWebPlugin } from "../types"; + +export const examplePlugin: PiWebPlugin = { + id: "example", + name: "Example Plugin", + activate: () => ({ + actions: [ + { + id: "workspace.show-path", + title: "Show Current Workspace Path", + group: "Example", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + const path = context.state.selectedWorkspace?.path ?? "No workspace selected"; + window.alert(path); + }, + }, + ], + workspacePanels: [ + { + id: "workspace.info", + title: "Info", + order: 100, + render: (context) => html` +
Info
+
+

Workspace

+

${context.workspace.label}

+

${context.workspace.path}

+
+ `, + }, + ], + }), +}; diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts new file mode 100644 index 0000000..f25d211 --- /dev/null +++ b/src/client/src/plugins/registry.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { initialAppState, type AppState } from "../appState"; +import { corePlugin } from "./core"; +import { PluginRegistry } from "./registry"; +import type { PluginRuntimeContext } from "./types"; + +function createContext(statePatch: Partial = {}) { + const calls: string[] = []; + const context: PluginRuntimeContext = { + state: { ...initialAppState(), ...statePatch }, + openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }), + focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }), + addProject: vi.fn(() => { calls.push("addProject"); }), + selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }), + selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }), + refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }), + refreshGit: vi.fn(() => { calls.push("refreshGit"); }), + startSession: vi.fn(() => { calls.push("startSession"); }), + archiveSession: vi.fn(() => { calls.push("archiveSession"); }), + stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }), + }; + return { context, calls }; +} + +describe("PluginRegistry", () => { + it("namespaces contribution ids with the owning plugin id", () => { + const registry = new PluginRegistry(); + registry.register(corePlugin); + + expect(registry.getActions(createContext().context).some((action) => action.id === "core:actions.show")).toBe(true); + expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git"]); + }); + + it("rejects duplicate ids within the same namespace", () => { + const registry = new PluginRegistry(); + + expect(() => { + registry.register({ + id: "example", + name: "Example", + activate: () => ({ + actions: [ + { id: "duplicate", title: "One", run: () => undefined }, + { id: "duplicate", title: "Two", run: () => undefined }, + ], + }), + }); + }).toThrow("Duplicate contribution id: example:duplicate"); + }); + + it("evaluates core action enablement against runtime state", () => { + const registry = new PluginRegistry(); + registry.register(corePlugin); + + const inactive = registry.getActions(createContext().context); + const active = registry.getActions(createContext({ selectedWorkspace: testWorkspace() }).context); + + expect(inactive.find((action) => action.id === "core:view.files")?.enabled).toBe(false); + expect(active.find((action) => action.id === "core:view.files")?.enabled).toBe(true); + }); + + it("routes refresh current to the active core workspace panel", () => { + const registry = new PluginRegistry(); + registry.register(corePlugin); + const { context, calls } = createContext({ + selectedWorkspace: testWorkspace(), + workspaceTool: "core:workspace.git", + }); + const action = registry.getActions(context).find((candidate) => candidate.id === "core:workspace.refresh-current"); + + if (action !== undefined) void action.run(); + + expect(calls).toEqual(["refreshGit"]); + }); +}); + +function testWorkspace(): AppState["selectedWorkspace"] { + return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitWorktree: false }; +} diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts new file mode 100644 index 0000000..56020d5 --- /dev/null +++ b/src/client/src/plugins/registry.ts @@ -0,0 +1,75 @@ +import type { PiWebPlugin, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspacePanelContribution, WorkspacePanelContribution } from "./types"; + +const idPattern = /^[a-z][a-z0-9.-]*$/u; +const localIdPattern = /^[a-z][a-z0-9.-]*$/u; + +type RegisteredPluginAction = Omit & { + id: QualifiedContributionId; + pluginId: string; + localId: string; +}; + +export class PluginRegistry { + private readonly actions: RegisteredPluginAction[] = []; + private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = []; + private readonly pluginIds = new Set(); + private readonly contributionIds = new Set(); + + register(plugin: PiWebPlugin): void { + this.validatePluginId(plugin.id); + if (this.pluginIds.has(plugin.id)) throw new Error(`Duplicate plugin id: ${plugin.id}`); + this.pluginIds.add(plugin.id); + + const contributions = plugin.activate({ apiVersion: 1 }); + for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(plugin.id, action)); + for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(plugin.id, panel)); + } + + getActions(context: PluginRuntimeContext): QualifiedPluginAction[] { + return this.actions.map((action) => { + const enabled = typeof action.enabled === "function" ? action.enabled(context) : action.enabled; + const qualified: QualifiedPluginAction = { + id: action.id, + pluginId: action.pluginId, + localId: action.localId, + title: action.title, + run: () => action.run(context), + }; + if (action.description !== undefined) qualified.description = action.description; + if (action.shortcut !== undefined) qualified.shortcut = action.shortcut; + if (action.group !== undefined) qualified.group = action.group; + if (enabled !== undefined) qualified.enabled = enabled; + return qualified; + }); + } + + getWorkspacePanels(): QualifiedWorkspacePanelContribution[] { + return [...this.workspacePanels].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.title.localeCompare(right.title)); + } + + private qualifyAction(pluginId: string, action: PluginAction): RegisteredPluginAction { + const id = this.qualify(pluginId, action.id); + return { ...action, id, pluginId, localId: action.id }; + } + + private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution { + const id = this.qualify(pluginId, panel.id); + return { ...panel, id, pluginId, localId: panel.id }; + } + + private qualify(pluginId: string, localId: string): QualifiedContributionId { + this.validateLocalId(localId); + const qualified: QualifiedContributionId = `${pluginId}:${localId}`; + if (this.contributionIds.has(qualified)) throw new Error(`Duplicate contribution id: ${qualified}`); + this.contributionIds.add(qualified); + return qualified; + } + + private validatePluginId(pluginId: string): void { + if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`); + } + + private validateLocalId(localId: string): void { + if (!localIdPattern.test(localId)) throw new Error(`Invalid contribution id: ${localId}`); + } +} diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts new file mode 100644 index 0000000..c3e54bb --- /dev/null +++ b/src/client/src/plugins/types.ts @@ -0,0 +1,84 @@ +import type { TemplateResult } from "lit"; +import type { AppAction } from "../actions"; +import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api"; +import type { AppState } from "../appState"; + +export type PluginId = string; +export type LocalContributionId = string; +export type QualifiedContributionId = `${PluginId}:${LocalContributionId}`; + +export interface PiWebPlugin { + id: PluginId; + name: string; + activate: (context: PluginActivationContext) => PluginContributions; +} + +export interface PluginActivationContext { + apiVersion: 1; +} + +export interface PluginContributions { + actions?: PluginAction[]; + workspacePanels?: WorkspacePanelContribution[]; +} + +export interface PluginRuntimeContext { + state: AppState; + openActionPalette: () => void; + focusPrompt: () => void; + addProject: () => void | Promise; + selectMainView: (view: AppState["mainView"]) => void; + selectWorkspaceTool: (tool: QualifiedContributionId) => void; + refreshFiles: () => void | Promise; + refreshGit: () => void | Promise; + startSession: () => void | Promise; + archiveSession: () => void | Promise; + stopActiveWork: () => void | Promise; +} + +export interface PluginAction { + id: LocalContributionId; + title: string; + description?: string; + shortcut?: string; + group?: string; + enabled?: boolean | ((context: PluginRuntimeContext) => boolean); + run: (context: PluginRuntimeContext) => void | Promise; +} + +export interface QualifiedPluginAction extends AppAction { + pluginId: PluginId; + localId: LocalContributionId; +} + +export interface WorkspacePanelContext { + workspace: Workspace; + fileTree: FileTreeEntry[]; + expandedDirs: Record; + selectedFilePath: string | undefined; + selectedFileContent: FileContentResponse | undefined; + fileTreeStale: boolean; + gitStatus: GitStatusResponse | undefined; + selectedDiffPath: string | undefined; + selectedDiff: GitDiffResponse | undefined; + selectedStagedDiff: GitDiffResponse | undefined; + gitStale: boolean; + onRefreshFiles: () => void; + onExpandDir: (path: string) => void; + onSelectFile: (path: string) => void; + onRefreshGit: () => void; + onSelectDiff: (path: string) => void; +} + +export interface WorkspacePanelContribution { + id: LocalContributionId; + title: string; + order?: number; + render: (context: WorkspacePanelContext) => TemplateResult; +} + +export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContribution { + id: QualifiedContributionId; + pluginId: PluginId; + localId: LocalContributionId; +} diff --git a/src/client/src/route.test.ts b/src/client/src/route.test.ts index cb0eb1f..c02bebf 100644 --- a/src/client/src/route.test.ts +++ b/src/client/src/route.test.ts @@ -36,8 +36,8 @@ describe("route helpers", () => { projectId: "p1", workspaceId: "w1", sessionId: "s1", - tool: "git", - view: "files", + tool: "core:workspace.git", + view: "core:workspace.files", file: "src/main.ts", diff: "README.md", }); @@ -55,7 +55,7 @@ describe("route helpers", () => { projectId: "project/id", workspaceId: "workspace id", sessionId: "", - tool: "files", + tool: "core:workspace.files", view: "chat", file: "src/main.ts", diff: undefined, @@ -63,13 +63,13 @@ describe("route helpers", () => { writeRoute(route); - expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=files&view=chat&file=src%2Fmain.ts#section"]); + expect(pushed).toEqual(["http://localhost/app?old=1&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat&file=src%2Fmain.ts#section"]); }); it("does not push history when the route is unchanged", () => { - const { pushed } = installWindow("http://localhost/app?project=p1&tool=git"); + const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git"); - writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "git", view: undefined, file: undefined, diff: undefined }); + writeRoute({ projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined, file: undefined, diff: undefined }); expect(pushed).toEqual([]); }); diff --git a/src/client/src/route.ts b/src/client/src/route.ts index aa4213e..84e325c 100644 --- a/src/client/src/route.ts +++ b/src/client/src/route.ts @@ -1,9 +1,11 @@ +import type { QualifiedContributionId } from "./plugins/types"; + export interface AppRoute { projectId: string | undefined; workspaceId: string | undefined; sessionId: string | undefined; - tool: "files" | "git" | undefined; - view: "chat" | "files" | "git" | undefined; + tool: QualifiedContributionId | undefined; + view: "chat" | QualifiedContributionId | undefined; file: string | undefined; diff: string | undefined; } @@ -42,10 +44,19 @@ export function writeRoute(route: AppRoute): void { if (next !== current) window.history.pushState({}, "", url); } -function parseTool(value: string | null): "files" | "git" | undefined { - return value === "files" || value === "git" ? value : undefined; +function parseTool(value: string | null): QualifiedContributionId | undefined { + if (value === "files") return "core:workspace.files"; + if (value === "git") return "core:workspace.git"; + return isQualifiedId(value) ? value : undefined; } -function parseView(value: string | null): "chat" | "files" | "git" | undefined { - return value === "chat" || value === "files" || value === "git" ? value : undefined; +function parseView(value: string | null): "chat" | QualifiedContributionId | undefined { + if (value === "chat") return "chat"; + if (value === "files") return "core:workspace.files"; + if (value === "git") return "core:workspace.git"; + return isQualifiedId(value) ? value : undefined; +} + +function isQualifiedId(value: string | null): value is QualifiedContributionId { + return value !== null && /^[a-z][a-z0-9.-]*:[a-z][a-z0-9.-]*$/u.test(value); }