Archived
Add workspace label plugin contributions
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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))}
|
||||
`;
|
||||
|
||||
@@ -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:");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([
|
||||
{ type: "link", text: "web", href: "http://localhost:5173" },
|
||||
{ type: "text", text: "last" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function testWorkspace(): AppState["selectedWorkspace"] {
|
||||
function testWorkspace(): Workspace {
|
||||
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user