diff --git a/.changeset/mobile-tab-icons.md b/.changeset/mobile-tab-icons.md index 27e4b79..fbd4b8d 100644 --- a/.changeset/mobile-tab-icons.md +++ b/.changeset/mobile-tab-icons.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Use compact icons and initials for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling. +Use compact icons and initials for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling, and let workspace panel plugins provide custom SVG tab icons. diff --git a/docs/plugins.md b/docs/plugins.md index a4730c8..7957508 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -100,11 +100,11 @@ Module shape excerpt: export default { apiVersion: 1, name: "Info Plugin", - activate: ({ html }) => ({ + activate: ({ html, svg }) => ({ contributions: { actions: [/* action definitions */], workspaceLabels: [/* compact label definitions */], - workspacePanels: [/* panel definitions using html */], + workspacePanels: [/* panel definitions using html, optional icons using svg */], }, }), }; @@ -239,6 +239,7 @@ interface PluginActivationContext { apiVersion: 1; pluginId: string; html: typeof import("lit").html; + svg: typeof import("lit").svg; } interface PluginActivationResult { @@ -369,6 +370,13 @@ workspacePanels: [ { id: "workspace.info", title: "Info", + icon: svg` + + + + + + `, order: 100, visible: ({ workspace }) => workspace.isGitRepo, render: ({ workspace }) => html` @@ -388,6 +396,7 @@ Panel type: interface WorkspacePanelContribution { id: string; title: string; + icon?: TemplateResult; order?: number; visible?: (context: { workspace: Workspace }) => boolean; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; @@ -400,6 +409,8 @@ interface WorkspacePanelContext { } ``` +`icon` is optional and is used in the compact mobile tab bar. Prefer an SVG rendered with the `svg` helper from `PluginActivationContext`; use `currentColor` so PI WEB themes can style it. If `icon` is omitted, mobile tabs fall back to initials from the panel title, or to the full title when initials collide. + `workspace` and `openTerminal()` are documented as stable for panel callbacks. Other fields may exist at runtime, but they are PI WEB internals and can change quickly. If a panel needs file, git, terminal, or session data beyond the helpers documented here, prefer explicit `fetch()` calls and keep them isolated. Useful workspace shape: diff --git a/plugin-api.d.ts b/plugin-api.d.ts index 96cca2d..ff2b9a8 100644 --- a/plugin-api.d.ts +++ b/plugin-api.d.ts @@ -4,6 +4,7 @@ export type PluginId = string; export type LocalContributionId = string; export type QualifiedContributionId = string; export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; +export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; export interface PiWebPlugin { apiVersion: 1; @@ -15,6 +16,7 @@ export interface PluginActivationContext { apiVersion: 1; pluginId: PluginId; html: HtmlTemplateTag; + svg: SvgTemplateTag; } export interface PluginActivationResult { @@ -84,9 +86,12 @@ export interface WorkspacePanelContext { openTerminal: (options?: { terminalId?: string | undefined }) => void; } +export type WorkspacePanelIcon = TemplateResult; + export interface WorkspacePanelContribution { id: LocalContributionId; title: string; + icon?: WorkspacePanelIcon; order?: number; visible?: (context: WorkspacePanelContext) => boolean; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index ce1b4c6..55d7fe7 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1021,7 +1021,7 @@ export class PiWebApp extends LitElement { { id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" }, { id: "chat", label: "Chat", icon: "chat" }, ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => { - const icon = this.mobilePanelIcon(panel); + const icon = panel.icon ?? this.mobilePanelIcon(panel); return { id: panel.id, label: panel.title, diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts index 44302e3..abf6562 100644 --- a/src/client/src/components/appShell/AppMobileMainTabs.ts +++ b/src/client/src/components/appShell/AppMobileMainTabs.ts @@ -1,8 +1,9 @@ -import { LitElement, css, html, svg } from "lit"; +import { LitElement, css, html, svg, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import type { AppState } from "../../appState"; -export type AppMobileMainTabIcon = "navigation" | "chat" | "files" | "git" | "terminal"; +export type AppMobileMainTabBuiltinIcon = "navigation" | "chat" | "files" | "git" | "terminal"; +export type AppMobileMainTabIcon = AppMobileMainTabBuiltinIcon | TemplateResult; export interface AppMobileMainTab { id: AppState["mainView"]; @@ -115,6 +116,7 @@ export class AppMobileMainTabs extends LitElement { } private renderIcon(icon: AppMobileMainTabIcon) { + if (typeof icon !== "string") return html``; switch (icon) { case "navigation": return svg` @@ -206,6 +208,8 @@ export class AppMobileMainTabs extends LitElement { .mobile-tabs .navigation-tab { display: none; } .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } .tab-icon { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + .tab-custom-icon { flex: 0 0 auto; width: 18px; height: 18px; display: inline-grid; place-items: center; color: currentColor; pointer-events: none; } + .tab-custom-icon svg { width: 18px; height: 18px; pointer-events: none; } .tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; } .tab-label { min-width: 0; } .tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 246ec4f..996bf5f 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -51,6 +51,31 @@ describe("PluginRegistry", () => { expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]); }); + it("provides html and svg helpers to plugin activation", () => { + const registry = new PluginRegistry(); + registry.register({ + id: "example", + plugin: { + apiVersion: 1, + name: "Example", + activate: ({ html, svg }) => ({ + contributions: { + workspacePanels: [ + { + id: "workspace.logs", + title: "Logs", + icon: svg``, + render: () => html`

Logs

`, + }, + ], + }, + }), + }, + }); + + expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined(); + }); + it("rejects duplicate ids within the same namespace", () => { const registry = new PluginRegistry(); diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 78f6a86..1822172 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, svg } from "lit"; import type { AppState } from "../appState"; import type { Workspace } from "../api"; import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types"; @@ -31,7 +31,7 @@ export class PluginRegistry { const apiVersion: unknown = plugin.apiVersion; if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`); - const result = plugin.activate({ apiVersion: 1, pluginId: id, html }); + const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg }); const contributions = result.contributions; for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action)); for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel)); diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 1f28529..41a1eb6 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -7,6 +7,7 @@ import type { LocalContributionId, PluginId, QualifiedContributionId } from "./i export type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids"; export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; +export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult; export interface PiWebPluginRegistration { id: PluginId; @@ -23,6 +24,7 @@ export interface PluginActivationContext { apiVersion: 1; pluginId: PluginId; html: HtmlTemplateTag; + svg: SvgTemplateTag; } export interface PluginActivationResult { @@ -118,9 +120,12 @@ export interface WorkspacePanelContext { onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void; } +export type WorkspacePanelIcon = TemplateResult; + export interface WorkspacePanelContribution { id: LocalContributionId; title: string; + icon?: WorkspacePanelIcon; order?: number; visible?: (context: WorkspacePanelVisibilityContext) => boolean; badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;