diff --git a/.changeset/dogfood-actions-plugin-package.md b/.changeset/dogfood-actions-plugin-package.md index 0583948..d63cd2a 100644 --- a/.changeset/dogfood-actions-plugin-package.md +++ b/.changeset/dogfood-actions-plugin-package.md @@ -3,4 +3,4 @@ "@jmfederico/pi-web-actions": patch --- -Document and harden separate Pi Web plugin package development, including the Actions plugin refresh flow and private API dogfooding notes. +Document and harden separate Pi Web plugin package development, including the Actions plugin refresh flow and public terminal navigation helper. diff --git a/docs/plugins.md b/docs/plugins.md index 09ccc61..b6b225f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -334,7 +334,7 @@ interface PluginRuntimeContext { configureAuth: () => void | Promise; logoutAuth: () => void | Promise; selectWorkspaceTool: (tool: QualifiedContributionId) => void; - openTerminal?: (options?: { terminalId?: string }) => void; + openTerminal: (options?: { terminalId?: string }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; startSession: () => void | Promise; @@ -397,11 +397,11 @@ interface WorkspacePanelContribution { interface WorkspacePanelContext { workspace: Workspace; - openTerminal?: (options?: { terminalId?: string }) => void; + openTerminal: (options?: { terminalId?: string }) => void; } ``` -`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. Use `openTerminal?.({ terminalId })` when a panel creates a terminal and wants Pi Web to navigate to that specific terminal. If a panel needs file, git, or session data, prefer explicit `fetch()` calls and keep them isolated. +`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. Use `openTerminal({ terminalId })` when a panel creates a terminal and wants Pi Web to navigate to that specific terminal. If a panel needs file, git, or session data, prefer explicit `fetch()` calls and keep them isolated. Useful workspace shape: diff --git a/plugin-api.d.ts b/plugin-api.d.ts index 722136d..1192303 100644 --- a/plugin-api.d.ts +++ b/plugin-api.d.ts @@ -47,7 +47,7 @@ export interface PluginRuntimeContext { openThemePicker: () => void; selectMainView: (view: string) => void; selectWorkspaceTool: (tool: QualifiedContributionId) => void; - openTerminal?: (options?: { terminalId?: string | undefined }) => void; + openTerminal: (options?: { terminalId?: string | undefined }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; startSession: () => void | Promise; @@ -79,7 +79,7 @@ export interface Workspace { export interface WorkspacePanelContext { workspace: Workspace; state?: PluginRuntimeState; - openTerminal?: (options?: { terminalId?: string | undefined }) => void; + openTerminal: (options?: { terminalId?: string | undefined }) => void; } export interface WorkspacePanelContribution { diff --git a/plugins/actions/README.md b/plugins/actions/README.md index 9cb1a94..fd19719 100644 --- a/plugins/actions/README.md +++ b/plugins/actions/README.md @@ -2,7 +2,7 @@ Configurable workspace actions for Pi Web. -The plugin adds an **Actions** workspace tab. Actions create a new Pi Web terminal, send the configured shell command, and switch to the Terminal tab so the user can monitor progress or take over. +The plugin adds an **Actions** workspace tab. Actions create a new Pi Web terminal, send the configured shell command, and switch to that terminal so the user can monitor progress or take over. ## Configuration diff --git a/plugins/actions/src/actionsPanelElement.ts b/plugins/actions/src/actionsPanelElement.ts index fb1ae28..3419951 100644 --- a/plugins/actions/src/actionsPanelElement.ts +++ b/plugins/actions/src/actionsPanelElement.ts @@ -1,7 +1,7 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api"; import { ACTIONS_CONFIG_PATH, type WorkspaceAction } from "./config.js"; import { createWorkspaceTerminal, sendTerminalCommand } from "./terminalDispatcher.js"; -import { openTerminalPanel, requestPiWebRender } from "./piWebPrivateUi.js"; +import { requestPiWebRender } from "./piWebPrivateUi.js"; import { loadWorkspaceActionsConfig, type WorkspaceActionsConfigLoadResult } from "./workspaceActionsClient.js"; export const actionsPanelTagName = "pi-web-actions-panel"; @@ -94,7 +94,7 @@ class PiWebActionsPanel extends HTMLElement { } this.root.querySelector("button[data-open-terminal]")?.addEventListener("click", () => { - this.openWorkspaceTerminal(workspace); + this.openWorkspaceTerminal(); }); } @@ -103,7 +103,7 @@ class PiWebActionsPanel extends HTMLElement { if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`; if (state.config.actions.length === 0) return `

No actions configured in ${escapeHtml(ACTIONS_CONFIG_PATH)}.

${this.renderStatus()}`; return ` -

Actions create a new workspace terminal, send the command, then switch to the Terminal tab. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

+

Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

${renderActionGroups(state.config.actions, this.runningActionId)} ${this.renderStatus()} `; @@ -148,7 +148,7 @@ class PiWebActionsPanel extends HTMLElement { }; this.runningActionId = undefined; this.render(); - this.openWorkspaceTerminal(workspace, terminal.id); + this.openWorkspaceTerminal(terminal.id); } catch (error) { this.runningActionId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; @@ -156,13 +156,14 @@ class PiWebActionsPanel extends HTMLElement { } } - private openWorkspaceTerminal(workspace: Workspace, terminalId?: string): void { - if (this.openTerminalValue !== undefined) { - if (terminalId === undefined) this.openTerminalValue(); - else this.openTerminalValue({ terminalId }); + private openWorkspaceTerminal(terminalId?: string): void { + if (this.openTerminalValue === undefined) { + this.status = { kind: "error", message: "This Pi Web version does not provide terminal navigation to plugins." }; + this.render(); return; } - openTerminalPanel(workspace, terminalId); + if (terminalId === undefined) this.openTerminalValue(); + else this.openTerminalValue({ terminalId }); } } diff --git a/plugins/actions/src/piWebPrivateUi.ts b/plugins/actions/src/piWebPrivateUi.ts index 1888463..c04181e 100644 --- a/plugins/actions/src/piWebPrivateUi.ts +++ b/plugins/actions/src/piWebPrivateUi.ts @@ -1,66 +1,12 @@ -import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -import { terminalToolId, type TerminalInfo } from "./terminalDispatcher.js"; - -interface TerminalPanelElement { - terminals: TerminalInfo[]; - selectTerminal: (terminalId: string) => void; -} - interface Updatable { requestUpdate: () => void; } -/** - * Private Pi Web UI fallback used while the plugin API is still being dogfooded. - * The current host provides a panel `openTerminal` helper; keep this fallback contained - * for older hosts and replace/remove it once the public helper is required. - */ -export function openTerminalPanel(workspace: Workspace, terminalId?: string): void { - const url = new URL(window.location.href); - url.searchParams.set("project", workspace.projectId); - url.searchParams.set("workspace", workspace.id); - url.searchParams.set("tool", terminalToolId); - url.searchParams.set("view", terminalToolId); - window.history.pushState({}, "", url); - dispatchPopState(); - if (terminalId !== undefined) selectTerminalWhenAvailable(terminalId); -} - export function requestPiWebRender(): void { const app = document.querySelector("pi-web-app"); if (isUpdatable(app)) app.requestUpdate(); } -function dispatchPopState(): void { - if (typeof PopStateEvent === "function") { - window.dispatchEvent(new PopStateEvent("popstate")); - return; - } - window.dispatchEvent(new Event("popstate")); -} - -function selectTerminalWhenAvailable(terminalId: string, attempt = 0): void { - const terminalPanel = findTerminalPanel(); - const terminals = terminalPanel?.terminals ?? []; - const hasTerminal = terminals.some((terminal) => terminal.id === terminalId); - - if (terminalPanel !== undefined && hasTerminal) { - terminalPanel.selectTerminal(terminalId); - return; - } - - if (attempt < 50) window.setTimeout(() => { selectTerminalWhenAvailable(terminalId, attempt + 1); }, 150); -} - -function findTerminalPanel(): TerminalPanelElement | undefined { - const panel = document.querySelector("workspace-panel")?.shadowRoot?.querySelector("terminal-panel"); - return isTerminalPanelElement(panel) ? panel : undefined; -} - -function isTerminalPanelElement(value: unknown): value is TerminalPanelElement { - return isRecord(value) && Array.isArray(value["terminals"]) && typeof value["selectTerminal"] === "function"; -} - function isUpdatable(value: unknown): value is Updatable { return isRecord(value) && typeof value["requestUpdate"] === "function"; } diff --git a/plugins/actions/src/terminalDispatcher.ts b/plugins/actions/src/terminalDispatcher.ts index 11b6d40..9607d6e 100644 --- a/plugins/actions/src/terminalDispatcher.ts +++ b/plugins/actions/src/terminalDispatcher.ts @@ -1,6 +1,5 @@ import type { Workspace } from "@jmfederico/pi-web/plugin-api"; -export const terminalToolId = "core:workspace.terminal"; export const actionTerminalCols = 120; export const actionTerminalRows = 32; diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 81fc64f..d891d11 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -52,6 +52,40 @@ export type AuthDialogState = | { step: "oauth"; flow: OAuthFlowState; responding?: boolean; inputValue?: string; error?: string } | { step: "logout"; providers: AuthProviderOption[] }; +export type WorkspaceScopedStateReset = Pick; + +export function resetWorkspaceScopedState(): WorkspaceScopedStateReset { + return { + sessions: [], + fileTree: [], + expandedDirs: {}, + selectedFilePath: undefined, + selectedFileContent: undefined, + fileTreeStale: false, + gitStatus: undefined, + selectedDiffPath: undefined, + selectedDiff: undefined, + selectedStagedDiff: undefined, + gitStale: false, + selectedTerminalId: undefined, + error: "", + }; +} + export function initialAppState(): AppState { return { projects: [], diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 3382176..1771fe0 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -414,8 +414,10 @@ export class PiWebApp extends LitElement { } private renderWorkspacePanel() { - const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace); - return html` { this.openTerminal(options); }} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(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)} .onSelectTerminal=${(terminalId: string | undefined, options?: { replace?: boolean | undefined }) => { this.selectTerminal(terminalId, options); }}>`; + const workspace = this.state.selectedWorkspace; + const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace); + const workspaceLabelItems = workspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, workspace); + return html` { this.openWorkspaceTool(tool); }}>`; } private renderNavigationPanel(autoSwitchToChat: boolean) { diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 02c7449..57eddeb 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -1,7 +1,6 @@ import { LitElement, html, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; -import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api"; -import type { AppState } from "../appState"; +import type { Workspace } from "../api"; import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import { workspacePanelStyles } from "./shared"; import { renderWorkspaceLabel } from "./workspaceLabel"; @@ -9,32 +8,12 @@ import { renderWorkspaceLabel } from "./workspaceLabel"; @customElement("workspace-panel") export class WorkspacePanel extends LitElement { @property({ attribute: false }) workspace: Workspace | undefined; - @property({ attribute: false }) appState!: AppState; + @property({ attribute: false }) panelContext: WorkspacePanelContext | 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 = {}; - @property({ attribute: false }) selectedFilePath: string | undefined; - @property({ attribute: false }) selectedFileContent: FileContentResponse | undefined; - @property({ type: Boolean }) fileTreeStale = false; - @property({ attribute: false }) gitStatus: GitStatusResponse | undefined; - @property({ attribute: false }) selectedDiffPath: string | undefined; - @property({ attribute: false }) selectedDiff: GitDiffResponse | undefined; - @property({ attribute: false }) selectedStagedDiff: GitDiffResponse | undefined; - @property({ type: Boolean }) gitStale = false; @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; - @property({ attribute: false }) onRefreshGit: () => void = () => undefined; - @property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined; - @property({ type: Number }) activeTerminalCount = 0; - @property({ attribute: false }) selectedTerminalId: string | undefined; - @property({ type: Boolean }) terminalAutoStart = false; - @property({ attribute: false }) openTerminal: (options?: { terminalId?: string | undefined }) => void = () => undefined; - @property({ attribute: false }) onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void = () => undefined; @query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null; @state() private workspaceHeaderCanScrollLeft = false; @state() private workspaceHeaderCanScrollRight = false; @@ -65,9 +44,10 @@ export class WorkspacePanel extends LitElement { override render() { const workspace = this.workspace; if (workspace === undefined) return html`
Select a workspace.
`; + const context = this.panelContext; + if (context === undefined) return html`
Workspace panel unavailable.
`; const visiblePanels = this.panels; const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0]; - const context = this.createPanelContext(workspace); return html`
@@ -128,32 +108,5 @@ export class WorkspacePanel extends LitElement { return strip instanceof HTMLElement ? strip : undefined; } - private createPanelContext(workspace: Workspace): WorkspacePanelContext { - return { - workspace, - state: this.appState, - 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, - activeTerminalCount: this.activeTerminalCount, - selectedTerminalId: this.selectedTerminalId, - terminalAutoStart: this.terminalAutoStart, - openTerminal: this.openTerminal, - onRefreshFiles: this.onRefreshFiles, - onExpandDir: this.onExpandDir, - onSelectFile: this.onSelectFile, - onRefreshGit: this.onRefreshGit, - onSelectDiff: this.onSelectDiff, - onSelectTerminal: this.onSelectTerminal, - }; - } - static override styles = workspacePanelStyles; } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index f65580b..df33124 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -1,4 +1,5 @@ import { api, type Project, type Workspace } from "../api"; +import { resetWorkspaceScopedState } from "../appState"; import { mergeCachedNewSessions } from "../cachedNewSessions"; import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types"; import type { SessionController } from "./sessionController"; @@ -15,7 +16,7 @@ export class WorkspaceController { clearSelection(options?: { updateUrl?: boolean | undefined }) { this.sessions.clearActiveSession(); - this.setState({ selectedProject: undefined, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, selectedTerminalId: undefined, error: "" }); + this.setState({ selectedProject: undefined, selectedWorkspace: undefined, workspaces: [], ...resetWorkspaceScopedState() }); if (options?.updateUrl !== false) this.updateUrl(); } @@ -27,7 +28,7 @@ export class WorkspaceController { async selectProject(project: Project, target?: RouteTarget) { this.sessions.clearActiveSession(); - this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, selectedTerminalId: undefined, error: "" }); + this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], ...resetWorkspaceScopedState() }); try { const workspaces = await api.workspaces(project.id); this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces } }); @@ -42,7 +43,7 @@ export class WorkspaceController { async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) { this.workspaceSelection.rememberWorkspace(workspace); this.sessions.clearActiveSession(); - this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, selectedTerminalId: undefined, error: "" }); + this.setState({ selectedWorkspace: workspace, ...resetWorkspaceScopedState() }); try { const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path)); this.setState({ sessions }); diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 2b37580..e4800d9 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -47,7 +47,7 @@ export interface PluginRuntimeContext { openThemePicker: () => void; selectMainView: (view: AppState["mainView"]) => void; selectWorkspaceTool: (tool: QualifiedContributionId) => void; - openTerminal?: (options?: { terminalId?: string | undefined }) => void; + openTerminal: (options?: { terminalId?: string | undefined }) => void; refreshFiles: () => void | Promise; refreshGit: () => void | Promise; startSession: () => void | Promise; @@ -91,7 +91,7 @@ export interface WorkspacePanelContext { activeTerminalCount: number; selectedTerminalId: string | undefined; terminalAutoStart: boolean; - openTerminal?: (options?: { terminalId?: string | undefined }) => void; + openTerminal: (options?: { terminalId?: string | undefined }) => void; onRefreshFiles: () => void; onExpandDir: (path: string) => void; onSelectFile: (path: string) => void;