From 1ae43998e7a105980050a9615c300edbfb7ad158 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 10:24:02 +0200 Subject: [PATCH] Add workspace label plugin contributions --- .gitignore | 3 ++ pi-web-plugins/info/pi-web-plugin.js | 7 ++++ src/client/src/components/PiWebApp.ts | 7 ++-- src/client/src/components/StatusBar.ts | 5 ++- src/client/src/components/WorkspaceList.ts | 22 +++++++++--- src/client/src/components/WorkspacePanel.ts | 6 ++-- src/client/src/components/shared.ts | 21 ++++++++++- src/client/src/components/workspaceLabel.ts | 30 ++++++++++++++++ src/client/src/plugins/example/index.ts | 7 ++++ src/client/src/plugins/registry.test.ts | 24 ++++++++++++- src/client/src/plugins/registry.ts | 23 +++++++++++- src/client/src/plugins/types.ts | 40 +++++++++++++++++++++ src/server/piWebPluginService.test.ts | 19 +++++++++- src/server/piWebPluginService.ts | 7 ++-- 14 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 src/client/src/components/workspaceLabel.ts diff --git a/.gitignore b/.gitignore index 087bb58..f430324 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dist/ .DS_Store *.log .playwright-cli/ + +# Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/. +/dev-plugins/ diff --git a/pi-web-plugins/info/pi-web-plugin.js b/pi-web-plugins/info/pi-web-plugin.js index 2766340..cd5a24d 100644 --- a/pi-web-plugins/info/pi-web-plugin.js +++ b/pi-web-plugins/info/pi-web-plugin.js @@ -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", diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index ccf1735..d8fd903 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -187,7 +187,8 @@ export class PiWebApp extends LitElement { } private renderWorkspacePanel(hideToolTabs = false) { - return html` { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}>`; + const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace); + return html` { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}>`; } private renderNavigationPanel(autoSwitchToChat: boolean) { @@ -202,7 +203,7 @@ export class PiWebApp extends LitElement { this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}> - openChatAfter(() => this.workspaces.selectWorkspace(workspace))}> + this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => openChatAfter(() => this.workspaces.selectWorkspace(workspace))}> 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)}> `; } @@ -306,7 +307,7 @@ export class PiWebApp extends LitElement { ${state.selectedSession ? html` 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())}> 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(); }}> - + ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts index 0c7ea55..f7f9a7c 100644 --- a/src/client/src/components/StatusBar.ts +++ b/src/client/src/components/StatusBar.ts @@ -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`
- ${this.workspace?.label ?? "workspace"} + ${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)} ↑${formatTokenCount(tokens.input)} ↓${formatTokenCount(tokens.output)} ${contextText} diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 8791f3a..e05d7ba 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -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`

Workspaces

- ${this.workspaces.map((workspace) => html` - - `)} + ${this.workspaces.map((workspace) => { + const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`; + return html` +
+
+ + + ${renderWorkspaceLabelItems(this.workspaceLabelItems(workspace))} + + ${workspace.path} +
+
+ `; + })}
`; } diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 4ac90f0..2353c20 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -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 = {}; @@ -41,7 +43,7 @@ export class WorkspacePanel extends LitElement { `)}
`} - ${workspace.label} + ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)} ${selectedPanel === undefined ? html`
No workspace panels registered.
` : selectedPanel.render(this.createPanelContext(workspace))} `; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 8b71d40..103868a 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/components/workspaceLabel.ts b/src/client/src/components/workspaceLabel.ts new file mode 100644 index 0000000..2eec0ba --- /dev/null +++ b/src/client/src/components/workspaceLabel.ts @@ -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` + + ${label} + ${renderWorkspaceLabelItems(items)} + + `; +} + +export function renderWorkspaceLabelItems(items: WorkspaceLabelItem[] = []): TemplateResult[] { + return items.map((item) => html`·${renderWorkspaceLabelItem(item)}`); +} + +function renderWorkspaceLabelItem(item: WorkspaceLabelItem): TemplateResult { + if (item.type === "render") return html`${item.render()}`; + if (item.type === "link" && isSafeHref(item.href)) { + const target = item.target ?? "_blank"; + const rel = target === "_blank" ? "noopener noreferrer" : undefined; + return html`${item.text}`; + } + return html`${item.text}`; +} + +function isSafeHref(href: string): boolean { + const trimmed = href.trim().toLowerCase(); + return trimmed !== "" && !trimmed.startsWith("javascript:") && !trimmed.startsWith("data:"); +} diff --git a/src/client/src/plugins/example/index.ts b/src/client/src/plugins/example/index.ts index 04f8030..76d87bf 100644 --- a/src/client/src/plugins/example/index.ts +++ b/src/client/src/plugins/example/index.ts @@ -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", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 25c575a..e2e009f 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -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 }; } diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 56020d5..4327204 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -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 & { export class PluginRegistry { private readonly actions: RegisteredPluginAction[] = []; private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = []; + private readonly workspaceLabelContributions: QualifiedWorkspaceLabelContribution[] = []; private readonly pluginIds = new Set(); private readonly contributionIds = new Set(); @@ -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}`; diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index cc81909..85a85d2 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -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; +} diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index c984cc6..1d176e1 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -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" } }, diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 3dba78d..81d7f91 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -159,8 +159,11 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise 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; }