Archived
Add client plugin contribution registry
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<CommandResult, { type: "select" }> | undefined;
|
||||
actionPaletteOpen: boolean;
|
||||
projectDialogOpen: boolean;
|
||||
workspaceTool: "files" | "git";
|
||||
mainView: "chat" | "files" | "git";
|
||||
workspaceTool: QualifiedContributionId;
|
||||
mainView: "chat" | QualifiedContributionId;
|
||||
fileTree: FileTreeEntry[];
|
||||
expandedDirs: Record<string, FileTreeEntry[]>;
|
||||
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: {},
|
||||
|
||||
@@ -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`<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[] {
|
||||
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 {
|
||||
<workspace-list .workspaces=${state.workspaces} .selected=${state.selectedWorkspace} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
|
||||
<session-list .sessions=${state.sessions} .statuses=${state.sessionStatuses} .activities=${state.sessionActivities} .selected=${state.selectedSession} .canStart=${!!state.selectedWorkspace} .onStart=${() => this.withChatScrollTransition(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => this.withChatScrollTransition(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => this.sessions.restoreSession(session)}></session-list>
|
||||
</aside>
|
||||
<main class=${`${state.mainView}-view`}>
|
||||
<main class=${state.mainView === "chat" ? "chat-view" : "workspace-view"}>
|
||||
<div class="mobile-tabs">
|
||||
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button>
|
||||
<button class=${state.mainView === "files" ? "selected" : ""} @click=${() => { this.selectMainView("files"); }}>Files</button>
|
||||
<button class=${state.mainView === "git" ? "selected" : ""} @click=${() => { this.selectMainView("git"); }}>Git</button>
|
||||
${this.plugins.getWorkspacePanels().map((panel) => html`
|
||||
<button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.selectMainView(panel.id); }}>${panel.title}</button>
|
||||
`)}
|
||||
</div>
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : 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;
|
||||
}
|
||||
|
||||
@@ -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<string, FileTreeEntry[]> = {};
|
||||
@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`<section class="empty">Select a workspace.</section>`;
|
||||
const selectedPanel = this.panels.find((panel) => panel.id === this.tool) ?? this.panels[0];
|
||||
return html`
|
||||
<header>
|
||||
<div class="tabs">
|
||||
<button class=${this.tool === "files" ? "selected" : ""} @click=${() => { this.onSelectTool("files"); }}>Files</button>
|
||||
<button class=${this.tool === "git" ? "selected" : ""} @click=${() => { this.onSelectTool("git"); }}>Git</button>
|
||||
${this.panels.map((panel) => html`
|
||||
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${panel.title}</button>
|
||||
`)}
|
||||
</div>
|
||||
<small title=${this.workspace.path}>${this.workspace.label}</small>
|
||||
</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() {
|
||||
return html`
|
||||
<section class="toolbar">
|
||||
<strong>Files</strong>
|
||||
${this.fileTreeStale ? html`<span class="stale">stale</span>` : null}
|
||||
<button @click=${this.onRefreshFiles}>Refresh</button>
|
||||
</section>
|
||||
<section class="split">
|
||||
<div class="list tree">
|
||||
${this.fileTree.length === 0 ? html`<p class="muted">No files loaded.</p>` : this.fileTree.map((entry) => this.renderTreeEntry(entry, 0))}
|
||||
</div>
|
||||
<div class="viewer">
|
||||
${this.renderFileViewer()}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTreeEntry(entry: FileTreeEntry, depth: number): TemplateResult {
|
||||
const children = this.expandedDirs[entry.path];
|
||||
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)}`;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -32,7 +32,7 @@ export class GitController {
|
||||
}
|
||||
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
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;
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
+17
-6
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user