Add client plugin contribution registry

This commit is contained in:
Federico Jaramillo Martinez
2026-05-08 15:27:20 +02:00
parent b0eac4157e
commit 87d0aa3b21
16 changed files with 535 additions and 290 deletions
-102
View File
@@ -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<AppState> = {}) {
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<NonNullable<AppState["status"]>> = {}): 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,
};
}
+4 -3
View File
@@ -1,5 +1,6 @@
import type { CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, 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"; import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types";
export interface AppState { export interface AppState {
projects: Project[]; projects: Project[];
@@ -19,8 +20,8 @@ export interface AppState {
commandDialog: Extract<CommandResult, { type: "select" }> | undefined; commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
actionPaletteOpen: boolean; actionPaletteOpen: boolean;
projectDialogOpen: boolean; projectDialogOpen: boolean;
workspaceTool: "files" | "git"; workspaceTool: QualifiedContributionId;
mainView: "chat" | "files" | "git"; mainView: "chat" | QualifiedContributionId;
fileTree: FileTreeEntry[]; fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>; expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined; selectedFilePath: string | undefined;
@@ -53,7 +54,7 @@ export function initialAppState(): AppState {
commandDialog: undefined, commandDialog: undefined,
actionPaletteOpen: false, actionPaletteOpen: false,
projectDialogOpen: false, projectDialogOpen: false,
workspaceTool: "files", workspaceTool: "core:workspace.files",
mainView: "chat", mainView: "chat",
fileTree: [], fileTree: [],
expandedDirs: {}, expandedDirs: {},
+37 -19
View File
@@ -2,7 +2,6 @@ import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
import type { Project, SessionInfo, Workspace } from "../api"; import type { Project, SessionInfo, Workspace } from "../api";
import type { AppAction } from "../actions"; import type { AppAction } from "../actions";
import { createAppActions } from "../appActions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { FileExplorerController } from "../controllers/fileExplorerController"; import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController"; import { GitController } from "../controllers/gitController";
@@ -10,6 +9,10 @@ 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";
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; 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 { readRoute, writeRoute } from "../route";
import "./ProjectList"; import "./ProjectList";
import "./WorkspaceList"; import "./WorkspaceList";
@@ -58,6 +61,7 @@ export class PiWebApp extends LitElement {
() => { this.updateUrl(); }, () => { this.updateUrl(); },
); );
private readonly keyboard = new KeyboardShortcutDispatcher(); private readonly keyboard = new KeyboardShortcutDispatcher();
private readonly plugins = createPluginRegistry();
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private readonly onKeyDown = (event: KeyboardEvent) => { private readonly onKeyDown = (event: KeyboardEvent) => {
if (this.keyboard.handle(event, this.getActions())) { 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); 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.files.refreshFiles(); if (route.tool === "core:workspace.files") await this.files.refreshFiles();
if (route.file !== undefined) await this.files.selectFile(route.file); 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); if (route.diff !== undefined) await this.git.selectDiff(route.diff);
this.git.updatePolling(); 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.setState({ workspaceTool: tool, mainView: tool });
this.updateUrl(); this.updateUrl();
if (tool === "files") void this.files.refreshFiles(); this.refreshSelectedWorkspaceTool(tool);
else void this.git.refreshGit();
this.git.updatePolling(); 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.setState({ mainView: view, workspaceTool: view === "chat" ? this.state.workspaceTool : view });
this.updateUrl(); this.updateUrl();
if (view === "files") void this.files.refreshFiles(); if (view !== "chat") this.refreshSelectedWorkspaceTool(view);
if (view === "git") void this.git.refreshGit();
this.git.updatePolling(); this.git.updatePolling();
} }
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.files.refreshFiles(); this.refreshSelectedWorkspaceTool(next.workspaceTool);
if (next.workspaceTool === "git") void this.git.refreshGit();
this.git.updatePolling(); this.git.updatePolling();
} }
@@ -168,28 +169,37 @@ 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.files.refreshFiles(); this.refreshSelectedWorkspaceTool(this.state.workspaceTool);
if (this.state.workspaceTool === "git") void this.git.refreshGit();
} }
} }
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() { 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} .selectedStagedDiff=${this.state.selectedStagedDiff} .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>`; return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .panels=${this.plugins.getWorkspacePanels()} .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} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: QualifiedContributionId) => { 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>`;
} }
private getActions(): AppAction[] { private getActions(): AppAction[] {
return createAppActions({ return this.plugins.getActions(this.createPluginRuntimeContext());
}
private createPluginRuntimeContext(): PluginRuntimeContext {
return {
state: this.state, state: this.state,
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); }, openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
focusPrompt: () => { this.promptEditor?.focusInput(); }, focusPrompt: () => { this.promptEditor?.focusInput(); },
addProject: () => { this.setState({ projectDialogOpen: true }); }, addProject: () => { this.setState({ projectDialogOpen: true }); },
selectMainView: (view) => { this.selectMainView(view); }, selectMainView: (view) => { this.selectMainView(view); },
selectWorkspaceTool: (tool) => { this.selectWorkspaceTool(tool); },
refreshFiles: () => this.files.refreshFiles(), refreshFiles: () => this.files.refreshFiles(),
refreshGit: () => this.git.refreshGit(), refreshGit: () => this.git.refreshGit(),
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()), startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
archiveSession: () => this.sessions.archiveSession(), archiveSession: () => this.sessions.archiveSession(),
stopActiveWork: () => this.sessions.stopActiveWork(), stopActiveWork: () => this.sessions.stopActiveWork(),
}); };
} }
private runAction(actionId: string) { private runAction(actionId: string) {
@@ -210,11 +220,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> <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> <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> </aside>
<main class=${`${state.mainView}-view`}> <main class=${state.mainView === "chat" ? "chat-view" : "workspace-view"}>
<div class="mobile-tabs"> <div class="mobile-tabs">
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button> <button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button>
<button class=${state.mainView === "files" ? "selected" : ""} @click=${() => { this.selectMainView("files"); }}>Files</button> ${this.plugins.getWorkspacePanels().map((panel) => html`
<button class=${state.mainView === "git" ? "selected" : ""} @click=${() => { this.selectMainView("git"); }}>Git</button> <button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.selectMainView(panel.id); }}>${panel.title}</button>
`)}
</div> </div>
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
${state.selectedSession ? html` ${state.selectedSession ? html`
@@ -235,6 +246,13 @@ export class PiWebApp extends LitElement {
static override styles = appStyles; 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 { 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;
} }
+29 -113
View File
@@ -1,13 +1,14 @@
import { LitElement, html, type TemplateResult } from "lit"; import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api"; import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared"; import { workspacePanelStyles } from "./shared";
import "./CodeViewer";
@customElement("workspace-panel") @customElement("workspace-panel")
export class WorkspacePanel extends LitElement { export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined; @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 }) fileTree: FileTreeEntry[] = [];
@property({ attribute: false }) expandedDirs: Record<string, FileTreeEntry[]> = {}; @property({ attribute: false }) expandedDirs: Record<string, FileTreeEntry[]> = {};
@property({ attribute: false }) selectedFilePath: string | undefined; @property({ attribute: false }) selectedFilePath: string | undefined;
@@ -18,7 +19,7 @@ export class WorkspacePanel extends LitElement {
@property({ attribute: false }) selectedDiff: GitDiffResponse | undefined; @property({ attribute: false }) selectedDiff: GitDiffResponse | undefined;
@property({ attribute: false }) selectedStagedDiff: GitDiffResponse | undefined; @property({ attribute: false }) selectedStagedDiff: GitDiffResponse | undefined;
@property({ type: Boolean }) gitStale = false; @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 }) onRefreshFiles: () => void = () => undefined;
@property({ attribute: false }) onExpandDir: (path: string) => void = () => undefined; @property({ attribute: false }) onExpandDir: (path: string) => void = () => undefined;
@property({ attribute: false }) onSelectFile: (path: string) => void = () => undefined; @property({ attribute: false }) onSelectFile: (path: string) => void = () => undefined;
@@ -27,125 +28,40 @@ export class WorkspacePanel extends LitElement {
override render() { override render() {
if (!this.workspace) return html`<section class="empty">Select a workspace.</section>`; if (!this.workspace) return html`<section class="empty">Select a workspace.</section>`;
const selectedPanel = this.panels.find((panel) => panel.id === this.tool) ?? this.panels[0];
return html` return html`
<header> <header>
<div class="tabs"> <div class="tabs">
<button class=${this.tool === "files" ? "selected" : ""} @click=${() => { this.onSelectTool("files"); }}>Files</button> ${this.panels.map((panel) => html`
<button class=${this.tool === "git" ? "selected" : ""} @click=${() => { this.onSelectTool("git"); }}>Git</button> <button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${panel.title}</button>
`)}
</div> </div>
<small title=${this.workspace.path}>${this.workspace.label}</small> <small title=${this.workspace.path}>${this.workspace.label}</small>
</header> </header>
${this.tool === "files" ? this.renderFiles() : this.renderGit()} ${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : selectedPanel.render(this.createPanelContext(this.workspace))}
`; `;
} }
private renderFiles() { private createPanelContext(workspace: Workspace): WorkspacePanelContext {
return html` return {
<section class="toolbar"> workspace,
<strong>Files</strong> fileTree: this.fileTree,
${this.fileTreeStale ? html`<span class="stale">stale</span>` : null} expandedDirs: this.expandedDirs,
<button @click=${this.onRefreshFiles}>Refresh</button> selectedFilePath: this.selectedFilePath,
</section> selectedFileContent: this.selectedFileContent,
<section class="split"> fileTreeStale: this.fileTreeStale,
<div class="list tree"> gitStatus: this.gitStatus,
${this.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : this.fileTree.map((entry) => this.renderTreeEntry(entry, 0))} selectedDiffPath: this.selectedDiffPath,
</div> selectedDiff: this.selectedDiff,
<div class="viewer"> selectedStagedDiff: this.selectedStagedDiff,
${this.renderFileViewer()} gitStale: this.gitStale,
</div> onRefreshFiles: this.onRefreshFiles,
</section> onExpandDir: this.onExpandDir,
`; onSelectFile: this.onSelectFile,
} onRefreshGit: this.onRefreshGit,
onSelectDiff: this.onSelectDiff,
private renderTreeEntry(entry: FileTreeEntry, depth: number): TemplateResult { };
const children = this.expandedDirs[entry.path];
const hasChildren = children !== undefined;
return html`
<button class="row" style=${`--depth:${String(depth)}`} @click=${() => { this.selectTreeEntry(entry); }}>
<span>${entry.type === "directory" ? (hasChildren ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${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`<p class="muted">Select a file.</p>`;
if (file === undefined) 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>
<code-viewer .content=${file.content} .language=${file.language}></code-viewer>
`;
}
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 ? html`<p class="muted">Not a git repository.</p>` : html`
<p class="summary">${this.gitSummary(status)}</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 === undefined || this.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
const unstaged = this.selectedDiff;
const staged = this.selectedStagedDiff;
if (unstaged === undefined || staged === undefined) return html`<p class="muted">Loading diff…</p>`;
const diffs = [staged, unstaged].filter((diff) => diff.diff !== "");
if (diffs.length === 0) return html`<p class="muted">No staged or unstaged diff.</p>`;
return html`
<div class=${diffs.length === 1 ? "diffs single" : "diffs"}>
${diffs.map((diff) => this.renderDiffSection(diff))}
</div>
`;
}
private renderDiffSection(diff: GitDiffResponse) {
return html`
<section class="diff-section">
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${diff.diff} .language=${"diff"}></code-viewer>
</section>
`;
}
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)}`;
} }
static override styles = workspacePanelStyles; static override styles = workspacePanelStyles;
} }
function stateLabel(index: string, workingTree: string): string {
const label = workingTree !== "unmodified" ? workingTree : index;
return label.slice(0, 1).toUpperCase();
}
+2 -3
View File
@@ -40,9 +40,8 @@ export const appStyles = css`
.shell { grid-template-columns: 340px minmax(0, 1fr); } .shell { grid-template-columns: 340px minmax(0, 1fr); }
.shell > workspace-panel { display: none; } .shell > workspace-panel { display: none; }
.mobile-tabs { display: flex; } .mobile-tabs { display: flex; }
main.files-view chat-view, main.files-view prompt-editor, main.files-view status-bar, main.workspace-view chat-view, main.workspace-view prompt-editor, main.workspace-view status-bar,
main.git-view chat-view, main.git-view prompt-editor, main.git-view status-bar, main.workspace-view .empty { display: none; }
main.files-view .empty, main.git-view .empty { display: none; }
main.chat-view .mobile-panel { display: none; } main.chat-view .mobile-panel { display: none; }
.mobile-panel { flex: 1 1 auto; min-height: 0; display: flex; } .mobile-panel { flex: 1 1 auto; min-height: 0; display: flex; }
.mobile-panel workspace-panel { flex: 1 1 auto; border-left: 0; } .mobile-panel workspace-panel { flex: 1 1 auto; border-left: 0; }
@@ -38,7 +38,7 @@ export class FileExplorerController {
const project = this.getState().selectedProject; const project = this.getState().selectedProject;
const workspace = this.getState().selectedWorkspace; const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return; 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(); this.updateUrl();
try { try {
this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" }); this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" });
+2 -2
View File
@@ -32,7 +32,7 @@ export class GitController {
} }
async selectDiff(path: string): Promise<void> { async selectDiff(path: string): Promise<void> {
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(); this.updateUrl();
await this.refreshDiff(path); await this.refreshDiff(path);
} }
@@ -55,7 +55,7 @@ export class GitController {
updatePolling(): void { updatePolling(): void {
this.dispose(); this.dispose();
const state = this.getState(); 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); this.pollTimer = window.setInterval(() => { void this.refreshGit(); }, 8000);
} }
} }
@@ -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 { export function createCoreActions(): PluginAction[] {
state: AppState;
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
selectMainView: (view: AppState["mainView"]) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;
}
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);
return [ return [
{ {
id: "actions.show", id: "actions.show",
@@ -26,28 +9,28 @@ export function createAppActions(context: AppActionContext): AppAction[] {
description: "Open the command palette", description: "Open the command palette",
shortcut: "mod+k", shortcut: "mod+k",
group: "General", group: "General",
run: context.openActionPalette, run: (context) => { context.openActionPalette(); },
}, },
{ {
id: "prompt.focus", id: "prompt.focus",
title: "Focus Prompt", title: "Focus Prompt",
description: "Move keyboard focus to the message composer", description: "Move keyboard focus to the message composer",
group: "General", group: "General",
enabled: hasSession, enabled: (context) => context.state.selectedSession !== undefined,
run: context.focusPrompt, run: (context) => { context.focusPrompt(); },
}, },
{ {
id: "project.add", id: "project.add",
title: "Add Project", title: "Add Project",
group: "Project", group: "Project",
run: context.addProject, run: (context) => context.addProject(),
}, },
{ {
id: "view.chat", id: "view.chat",
title: "Go to Chat", title: "Go to Chat",
shortcut: "mod+1", shortcut: "mod+1",
group: "Navigation", group: "Navigation",
run: () => { context.selectMainView("chat"); }, run: (context) => { context.selectMainView("chat"); },
}, },
{ {
id: "view.files", id: "view.files",
@@ -55,7 +38,7 @@ export function createAppActions(context: AppActionContext): AppAction[] {
shortcut: "mod+2", shortcut: "mod+2",
group: "Navigation", group: "Navigation",
enabled: hasWorkspace, enabled: hasWorkspace,
run: () => { context.selectMainView("files"); }, run: (context) => { context.selectMainView("core:workspace.files"); },
}, },
{ {
id: "view.git", id: "view.git",
@@ -63,7 +46,7 @@ export function createAppActions(context: AppActionContext): AppAction[] {
shortcut: "mod+3", shortcut: "mod+3",
group: "Navigation", group: "Navigation",
enabled: hasWorkspace, enabled: hasWorkspace,
run: () => { context.selectMainView("git"); }, run: (context) => { context.selectMainView("core:workspace.git"); },
}, },
{ {
id: "workspace.refresh-files", id: "workspace.refresh-files",
@@ -71,7 +54,7 @@ export function createAppActions(context: AppActionContext): AppAction[] {
shortcut: "mod+shift+f", shortcut: "mod+shift+f",
group: "Workspace", group: "Workspace",
enabled: hasWorkspace, enabled: hasWorkspace,
run: context.refreshFiles, run: (context) => context.refreshFiles(),
}, },
{ {
id: "workspace.refresh-git", id: "workspace.refresh-git",
@@ -79,7 +62,7 @@ export function createAppActions(context: AppActionContext): AppAction[] {
shortcut: "mod+shift+g", shortcut: "mod+shift+g",
group: "Workspace", group: "Workspace",
enabled: hasWorkspace, enabled: hasWorkspace,
run: context.refreshGit, run: (context) => context.refreshGit(),
}, },
{ {
id: "workspace.refresh-current", id: "workspace.refresh-current",
@@ -87,7 +70,7 @@ export function createAppActions(context: AppActionContext): AppAction[] {
shortcut: "mod+shift+r", shortcut: "mod+shift+r",
group: "Workspace", group: "Workspace",
enabled: hasWorkspace, 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", id: "session.start",
@@ -95,27 +78,31 @@ export function createAppActions(context: AppActionContext): AppAction[] {
shortcut: "mod+enter", shortcut: "mod+enter",
group: "Session", group: "Session",
enabled: hasWorkspace, enabled: hasWorkspace,
run: context.startSession, run: (context) => context.startSession(),
}, },
{ {
id: "session.archive", id: "session.archive",
title: "Archive Session", title: "Archive Session",
description: "Archive the selected session", description: "Archive the selected session",
group: "Session", group: "Session",
enabled: canArchiveSession, enabled: (context) => context.state.selectedSession !== undefined && context.state.selectedSession.archived !== true,
run: context.archiveSession, run: (context) => context.archiveSession(),
}, },
{ {
id: "session.stop", id: "session.stop",
title: "Stop Active Work", title: "Stop Active Work",
shortcut: "mod+.", shortcut: "mod+.",
group: "Session", group: "Session",
enabled: hasSession && isBusy, enabled: (context) => context.state.selectedSession !== undefined && isActive(context.state.status),
run: context.stopActiveWork, run: (context) => context.stopActiveWork(),
}, },
]; ];
} }
function hasWorkspace(context: { state: AppState }): boolean {
return context.state.selectedWorkspace !== undefined;
}
function isActive(status: AppState["status"]): boolean { 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;
} }
+12
View File
@@ -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(),
}),
};
+129
View File
@@ -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`
<section class="toolbar">
<strong>Files</strong>
${context.fileTreeStale ? html`<span class="stale">stale</span>` : null}
<button @click=${context.onRefreshFiles}>Refresh</button>
</section>
<section class="split">
<div class="list tree">
${context.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : context.fileTree.map((entry) => renderTreeEntry(context, entry, 0))}
</div>
<div class="viewer">
${renderFileViewer(context)}
</div>
</section>
`;
}
function renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult {
const children = context.expandedDirs[entry.path];
const hasChildren = children !== undefined;
return html`
<button class="row" style=${`--depth:${String(depth)}`} @click=${() => { selectTreeEntry(context, entry); }}>
<span>${entry.type === "directory" ? (hasChildren ? "▾" : "▸") : "·"}</span>
<span>${entry.name}</span>
</button>
${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`<p class="muted">Select a file.</p>`;
if (file === undefined) return html`<p class="muted">Loading ${context.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>
<code-viewer .content=${file.content} .language=${file.language}></code-viewer>
`;
}
function renderGit(context: WorkspacePanelContext): TemplateResult {
const status = context.gitStatus;
return html`
<section class="toolbar">
<strong>Git</strong>
${context.gitStale ? html`<span class="stale">stale</span>` : null}
<button @click=${context.onRefreshGit}>Refresh</button>
</section>
<section class="split">
<div class="list">
${status === undefined ? html`<p class="muted">No status loaded.</p>` : !status.isGitRepo ? html`<p class="muted">Not a git repository.</p>` : html`
<p class="summary">${gitSummary(status)}</p>
${status.files.length === 0 ? html`<p class="muted">No changes.</p>` : status.files.map((file) => html`
<button class="row ${context.selectedDiffPath === file.path ? "selected" : ""}" @click=${() => { context.onSelectDiff(file.path); }}>
<span>${stateLabel(file.index, file.workingTree)}</span>
<span>${file.path}</span>
</button>
`)}
`}
</div>
<div class="viewer">
${renderDiffViewer(context)}
</div>
</section>
`;
}
function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
const unstaged = context.selectedDiff;
const staged = context.selectedStagedDiff;
if (unstaged === undefined || staged === undefined) return html`<p class="muted">Loading diff…</p>`;
const diffs = [staged, unstaged].filter((diff) => diff.diff !== "");
if (diffs.length === 0) return html`<p class="muted">No staged or unstaged diff.</p>`;
return html`
<div class=${diffs.length === 1 ? "diffs single" : "diffs"}>
${diffs.map((diff) => renderDiffSection(diff))}
</div>
`;
}
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
return html`
<section class="diff-section">
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${diff.diff} .language=${"diff"}></code-viewer>
</section>
`;
}
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();
}
+36
View File
@@ -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`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
},
],
}),
};
+79
View File
@@ -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<AppState> = {}) {
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 };
}
+75
View File
@@ -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<PluginAction, "id"> & {
id: QualifiedContributionId;
pluginId: string;
localId: string;
};
export class PluginRegistry {
private readonly actions: RegisteredPluginAction[] = [];
private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = [];
private readonly pluginIds = new Set<string>();
private readonly contributionIds = new Set<QualifiedContributionId>();
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}`);
}
}
+84
View File
@@ -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<void>;
selectMainView: (view: AppState["mainView"]) => void;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;
}
export interface PluginAction {
id: LocalContributionId;
title: string;
description?: string;
shortcut?: string;
group?: string;
enabled?: boolean | ((context: PluginRuntimeContext) => boolean);
run: (context: PluginRuntimeContext) => void | Promise<void>;
}
export interface QualifiedPluginAction extends AppAction {
pluginId: PluginId;
localId: LocalContributionId;
}
export interface WorkspacePanelContext {
workspace: Workspace;
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
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;
}
+6 -6
View File
@@ -36,8 +36,8 @@ describe("route helpers", () => {
projectId: "p1", projectId: "p1",
workspaceId: "w1", workspaceId: "w1",
sessionId: "s1", sessionId: "s1",
tool: "git", tool: "core:workspace.git",
view: "files", view: "core:workspace.files",
file: "src/main.ts", file: "src/main.ts",
diff: "README.md", diff: "README.md",
}); });
@@ -55,7 +55,7 @@ describe("route helpers", () => {
projectId: "project/id", projectId: "project/id",
workspaceId: "workspace id", workspaceId: "workspace id",
sessionId: "", sessionId: "",
tool: "files", tool: "core:workspace.files",
view: "chat", view: "chat",
file: "src/main.ts", file: "src/main.ts",
diff: undefined, diff: undefined,
@@ -63,13 +63,13 @@ describe("route helpers", () => {
writeRoute(route); 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", () => { 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([]); expect(pushed).toEqual([]);
}); });
+17 -6
View File
@@ -1,9 +1,11 @@
import type { QualifiedContributionId } from "./plugins/types";
export interface AppRoute { export interface AppRoute {
projectId: string | undefined; projectId: string | undefined;
workspaceId: string | undefined; workspaceId: string | undefined;
sessionId: string | undefined; sessionId: string | undefined;
tool: "files" | "git" | undefined; tool: QualifiedContributionId | undefined;
view: "chat" | "files" | "git" | undefined; view: "chat" | QualifiedContributionId | undefined;
file: string | undefined; file: string | undefined;
diff: string | undefined; diff: string | undefined;
} }
@@ -42,10 +44,19 @@ export function writeRoute(route: AppRoute): void {
if (next !== current) window.history.pushState({}, "", url); if (next !== current) window.history.pushState({}, "", url);
} }
function parseTool(value: string | null): "files" | "git" | undefined { function parseTool(value: string | null): QualifiedContributionId | undefined {
return value === "files" || value === "git" ? value : 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 { function parseView(value: string | null): "chat" | QualifiedContributionId | undefined {
return value === "chat" || value === "files" || value === "git" ? value : 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);
} }