Add workspace label plugin contributions

This commit is contained in:
Federico Jaramillo Martinez
2026-05-11 10:24:02 +02:00
parent f2ffc0bc76
commit 1ae43998e7
14 changed files with 204 additions and 17 deletions
+3
View File
@@ -4,3 +4,6 @@ dist/
.DS_Store
*.log
.playwright-cli/
# Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/<plugin-id>.
/dev-plugins/
+7
View File
@@ -16,6 +16,13 @@ export default {
},
},
],
workspaceLabelContributions: [
{
id: "workspace.path-label",
order: 100,
items: (context) => ({ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }),
},
],
workspacePanels: [
{
id: "workspace.info",
+4 -3
View File
@@ -187,7 +187,8 @@ export class PiWebApp extends LitElement {
}
private renderWorkspacePanel(hideToolTabs = false) {
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .hideToolTabs=${hideToolTabs} .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>`;
const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace);
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .hideToolTabs=${hideToolTabs} .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 renderNavigationPanel(autoSwitchToChat: boolean) {
@@ -202,7 +203,7 @@ export class PiWebApp extends LitElement {
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
</header>
<project-list .projects=${this.state.projects} .selected=${this.state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}></project-list>
<workspace-list .workspaces=${this.state.workspaces} .selected=${this.state.selectedWorkspace} .onSelect=${(workspace: Workspace) => openChatAfter(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<workspace-list .workspaces=${this.state.workspaces} .selected=${this.state.selectedWorkspace} .workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => openChatAfter(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .onStart=${() => openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}></session-list>
`;
}
@@ -306,7 +307,7 @@ export class PiWebApp extends LitElement {
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
+4 -1
View File
@@ -1,13 +1,16 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { SessionStatus, Workspace } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import { formatCost, formatTokenCount } from "../utils/format";
import { statusBarStyles } from "./shared";
import { renderWorkspaceLabel } from "./workspaceLabel";
@customElement("status-bar")
export class StatusBar extends LitElement {
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) workspace?: Workspace;
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
override render() {
const status = this.status;
@@ -21,7 +24,7 @@ export class StatusBar extends LitElement {
const tokens = status.tokens;
return html`
<div class="bar">
<span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span>
<span>${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)}</span>
<span>↑${formatTokenCount(tokens.input)}</span>
<span>↓${formatTokenCount(tokens.output)}</span>
<span>${contextText}</span>
+17 -5
View File
@@ -1,23 +1,35 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { Workspace } from "../api";
import type { WorkspaceLabelItem } from "../plugins/types";
import { listStyles } from "./shared";
import { renderWorkspaceLabelItems } from "./workspaceLabel";
@customElement("workspace-list")
export class WorkspaceList extends LitElement {
@property({ attribute: false }) workspaces: Workspace[] = [];
@property({ attribute: false }) selected?: Workspace;
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
override render() {
return html`
<section>
<h2>Workspaces</h2>
${this.workspaces.map((workspace) => html`
<button class=${this.selected?.id === workspace.id ? "selected" : ""} @click=${() => this.onSelect?.(workspace)}>
<span>${workspace.label}${workspace.isMain ? " · main" : ""}</span><small>${workspace.path}</small>
</button>
`)}
${this.workspaces.map((workspace) => {
const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`;
return html`
<div class=${this.selected?.id === workspace.id ? "workspace-row selected" : "workspace-row"}>
<div class="workspace-main">
<span class="workspace-label">
<button class="workspace-select" title=${workspace.path} @click=${() => this.onSelect?.(workspace)}>${label}</button>
${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))}
</span>
<small>${workspace.path}</small>
</div>
</div>
`;
})}
</section>
`;
}
+4 -2
View File
@@ -1,14 +1,16 @@
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 type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared";
import { renderWorkspaceLabel } from "./workspaceLabel";
@customElement("workspace-panel")
export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined;
@property() tool: QualifiedContributionId = "core:workspace.files";
@property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = [];
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
@property({ type: Boolean }) hideToolTabs = false;
@property({ attribute: false }) fileTree: FileTreeEntry[] = [];
@property({ attribute: false }) expandedDirs: Record<string, FileTreeEntry[]> = {};
@@ -41,7 +43,7 @@ export class WorkspacePanel extends LitElement {
`)}
</div>
`}
<small title=${workspace.path}>${workspace.label}</small>
<small>${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}</small>
</header>
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : selectedPanel.render(this.createPanelContext(workspace))}
`;
+20 -1
View File
@@ -73,7 +73,12 @@ export const workspacePanelStyles = css`
button { border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; }
button.selected { border-color: #58a6ff; background: #0d2847; }
small, .muted { color: #8b949e; }
header small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: #8b949e; }
.workspace-label-link { color: #58a6ff; text-decoration: none; }
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
.toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #21262d; }
.toolbar button { margin-left: auto; }
.stale { border: 1px solid #6e5200; border-radius: 999px; color: #d29922; padding: 1px 6px; font-size: 12px; }
@@ -101,6 +106,10 @@ export const listStyles = css`
h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; }
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
.workspace-row { margin: 6px 0; }
.workspace-row.selected .workspace-main { border-color: #58a6ff; background: #0d2847; }
.workspace-main { box-sizing: border-box; display: block; width: 100%; border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 7px 9px; text-align: left; }
.workspace-select { min-width: 0; border: 0; background: transparent; color: #e6edf3; padding: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; }
.subheading { margin-top: 14px; }
.section-toggle { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-transform: inherit; }
.section-toggle small { display: inline; color: inherit; font-size: inherit; }
@@ -119,6 +128,11 @@ export const listStyles = css`
button.selected { border-color: #58a6ff; background: #0d2847; }
button:disabled { opacity: .5; cursor: not-allowed; }
small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: #8b949e; }
.workspace-label-link { color: #58a6ff; text-decoration: none; }
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
`;
export const chatStyles = css`
@@ -224,6 +238,11 @@ export const statusBarStyles = css`
:host { display: block; color: #8b949e; font: 12px system-ui, sans-serif; }
.bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid #30363d; background: #0d1117; white-space: nowrap; overflow: hidden; }
span { overflow: hidden; text-overflow: ellipsis; }
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: #8b949e; }
.workspace-label-link { color: #58a6ff; text-decoration: none; }
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
.bar > span:first-child { flex: 1 1 auto; min-width: 80px; }
.activity { display: inline-flex; align-items: center; gap: 6px; color: #8b949e; }
.activity.active { color: #3fb950; }
@@ -0,0 +1,30 @@
import { html, type TemplateResult } from "lit";
import type { WorkspaceLabelItem } from "../plugins/types";
export function renderWorkspaceLabel(label: string, items: WorkspaceLabelItem[] = [], title?: string): TemplateResult {
return html`
<span class="workspace-label">
<span class="workspace-label-base" title=${title ?? label}>${label}</span>
${renderWorkspaceLabelItems(items)}
</span>
`;
}
export function renderWorkspaceLabelItems(items: WorkspaceLabelItem[] = []): TemplateResult[] {
return items.map((item) => html`<span class="workspace-label-separator">·</span>${renderWorkspaceLabelItem(item)}`);
}
function renderWorkspaceLabelItem(item: WorkspaceLabelItem): TemplateResult {
if (item.type === "render") return html`<span class="workspace-label-render">${item.render()}</span>`;
if (item.type === "link" && isSafeHref(item.href)) {
const target = item.target ?? "_blank";
const rel = target === "_blank" ? "noopener noreferrer" : undefined;
return html`<a class="workspace-label-item workspace-label-link" href=${item.href} title=${item.title ?? item.text} target=${target} rel=${rel ?? undefined}>${item.text}</a>`;
}
return html`<span class="workspace-label-item" title=${item.title ?? item.text}>${item.text}</span>`;
}
function isSafeHref(href: string): boolean {
const trimmed = href.trim().toLowerCase();
return trimmed !== "" && !trimmed.startsWith("javascript:") && !trimmed.startsWith("data:");
}
+7
View File
@@ -17,6 +17,13 @@ export const examplePlugin: PiWebPlugin = {
},
},
],
workspaceLabelContributions: [
{
id: "workspace.example-label",
order: 100,
items: (context) => ({ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }),
},
],
workspacePanels: [
{
id: "workspace.info",
+23 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { corePlugin } from "./core";
import { PluginRegistry } from "./registry";
@@ -72,8 +73,29 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["refreshGit"]);
});
it("collects workspace label items in contribution order", () => {
const registry = new PluginRegistry();
const workspace = testWorkspace();
registry.register({
id: "example",
name: "Example",
activate: () => ({
workspaceLabelContributions: [
{ id: "last", order: 20, items: () => ({ type: "text", text: "last" }) },
{ id: "hidden", order: 5, visible: () => false, items: () => ({ type: "text", text: "hidden" }) },
{ id: "first", order: 10, items: () => [{ type: "link", text: "web", href: "http://localhost:5173" }] },
],
}),
});
function testWorkspace(): AppState["selectedWorkspace"] {
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([
{ type: "link", text: "web", href: "http://localhost:5173" },
{ type: "text", text: "last" },
]);
});
});
function testWorkspace(): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
}
+22 -1
View File
@@ -1,4 +1,6 @@
import type { PiWebPlugin, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspacePanelContribution, WorkspacePanelContribution } from "./types";
import type { AppState } from "../appState";
import type { Workspace } from "../api";
import type { PiWebPlugin, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
const idPattern = /^[a-z][a-z0-9.-]*$/u;
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
@@ -12,6 +14,7 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
export class PluginRegistry {
private readonly actions: RegisteredPluginAction[] = [];
private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = [];
private readonly workspaceLabelContributions: QualifiedWorkspaceLabelContribution[] = [];
private readonly pluginIds = new Set<string>();
private readonly contributionIds = new Set<QualifiedContributionId>();
@@ -23,6 +26,7 @@ export class PluginRegistry {
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));
for (const contribution of contributions.workspaceLabelContributions ?? []) this.workspaceLabelContributions.push(this.qualifyWorkspaceLabelContribution(plugin.id, contribution));
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
@@ -47,6 +51,18 @@ export class PluginRegistry {
return [...this.workspacePanels].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.title.localeCompare(right.title));
}
getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] {
const context = { state, workspace };
return [...this.workspaceLabelContributions]
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
.flatMap((contribution) => {
if (contribution.visible?.(context) === false) return [];
const items = contribution.items(context);
if (items === undefined) return [];
return Array.isArray(items) ? items : [items];
});
}
private qualifyAction(pluginId: string, action: PluginAction): RegisteredPluginAction {
const id = this.qualify(pluginId, action.id);
return { ...action, id, pluginId, localId: action.id };
@@ -57,6 +73,11 @@ export class PluginRegistry {
return { ...panel, id, pluginId, localId: panel.id };
}
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution): QualifiedWorkspaceLabelContribution {
const id = this.qualify(pluginId, contribution.id);
return { ...contribution, id, pluginId, localId: contribution.id };
}
private qualify(pluginId: string, localId: string): QualifiedContributionId {
this.validateLocalId(localId);
const qualified: QualifiedContributionId = `${pluginId}:${localId}`;
+40
View File
@@ -20,6 +20,7 @@ export interface PluginActivationContext {
export interface PluginContributions {
actions?: PluginAction[];
workspacePanels?: WorkspacePanelContribution[];
workspaceLabelContributions?: WorkspaceLabelContribution[];
}
export interface PluginRuntimeContext {
@@ -83,3 +84,42 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr
pluginId: PluginId;
localId: LocalContributionId;
}
export interface WorkspaceLabelContext {
workspace: Workspace;
state: AppState;
}
export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem;
export interface WorkspaceLabelTextItem {
type: "text";
text: string;
title?: string;
}
export interface WorkspaceLabelLinkItem {
type: "link";
text: string;
href: string;
title?: string;
target?: "_blank" | "_self";
}
export interface WorkspaceLabelRenderItem {
type: "render";
render: () => TemplateResult;
}
export interface WorkspaceLabelContribution {
id: LocalContributionId;
order?: number;
visible?: (context: WorkspaceLabelContext) => boolean;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem | WorkspaceLabelItem[] | undefined;
}
export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContribution {
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
}
+18 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -54,6 +54,23 @@ describe("PiWebPluginService", () => {
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
});
it("discovers local plugins through symlinks for development", async () => {
const pluginDir = join(tempDir, "dev-plugin");
await writePlugin(pluginDir, {
packageJson: { piWeb: { id: "dev", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default { id: 'dev' };" },
});
await mkdir(join(tempDir, "plugins"), { recursive: true });
await symlink(pluginDir, join(tempDir, "plugins", "dev"), "dir");
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest();
expect(manifest.plugins).toHaveLength(1);
expect(manifest.plugins[0]).toMatchObject({ id: "dev", source: "test", scope: "local" });
await expect(service.readAsset("dev", "pi-web-plugin.js")).resolves.toBeDefined();
});
it("keeps duplicate plugin ids addressable", async () => {
await writePlugin(join(tempDir, "plugins", "one"), {
packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } },
+5 -2
View File
@@ -159,8 +159,11 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]>
const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
const plugins: PluginRecord[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !pluginIdPattern.test(entry.name)) continue;
plugins.push(...await discoverLocalPlugin(join(root.path, entry.name), entry.name, root));
if (!pluginIdPattern.test(entry.name)) continue;
const pluginRoot = join(root.path, entry.name);
const pluginStat = entry.isDirectory() ? undefined : entry.isSymbolicLink() ? await stat(pluginRoot).catch(() => undefined) : undefined;
if (!entry.isDirectory() && pluginStat?.isDirectory() !== true) continue;
plugins.push(...await discoverLocalPlugin(pluginRoot, entry.name, root));
}
return plugins;
}