refactor: simplify workspace panel terminal flow

This commit is contained in:
Federico Jaramillo Martinez
2026-05-21 09:31:33 +02:00
parent fb7903f3cb
commit c740ac3dcb
12 changed files with 65 additions and 129 deletions
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -334,7 +334,7 @@ interface PluginRuntimeContext {
configureAuth: () => void | Promise<void>;
logoutAuth: () => void | Promise<void>;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
openTerminal?: (options?: { terminalId?: string }) => void;
openTerminal: (options?: { terminalId?: string }) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
@@ -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:
+2 -2
View File
@@ -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<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
@@ -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 {
+1 -1
View File
@@ -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
+10 -9
View File
@@ -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 `<p class="muted">No actions configured in ${escapeHtml(ACTIONS_CONFIG_PATH)}.</p>${this.renderStatus()}`;
return `
<p class="muted">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.</p>
<p class="muted">Actions create a new workspace terminal, send the command, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
${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 });
}
}
-54
View File
@@ -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";
}
@@ -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;
+34
View File
@@ -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<AppState,
| "sessions"
| "fileTree"
| "expandedDirs"
| "selectedFilePath"
| "selectedFileContent"
| "fileTreeStale"
| "gitStatus"
| "selectedDiffPath"
| "selectedDiff"
| "selectedStagedDiff"
| "gitStale"
| "selectedTerminalId"
| "error"
>;
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: [],
+4 -2
View File
@@ -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`<workspace-panel .workspace=${this.state.selectedWorkspace} .appState=${this.state} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .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} .activeTerminalCount=${this.state.activeTerminalCount} .selectedTerminalId=${this.state.selectedTerminalId} .terminalAutoStart=${this.terminalAutoStartWorkspaceId === this.state.selectedWorkspace?.id} .openTerminal=${(options?: { terminalId?: string | undefined }) => { 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); }}></workspace-panel>`;
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`<workspace-panel .workspace=${workspace} .panelContext=${panelContext} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}></workspace-panel>`;
}
private renderNavigationPanel(autoSwitchToChat: boolean) {
+4 -51
View File
@@ -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<string, FileTreeEntry[]> = {};
@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`<section class="empty">Select a workspace.</section>`;
const context = this.panelContext;
if (context === undefined) return html`<section class="empty">Workspace panel unavailable.</section>`;
const visiblePanels = this.panels;
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
const context = this.createPanelContext(workspace);
return html`
<header>
<div class=${this.workspaceHeaderFrameClass()}>
@@ -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;
}
@@ -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 });
+2 -2
View File
@@ -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<void>;
refreshGit: () => void | Promise<void>;
startSession: () => void | Promise<void>;
@@ -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;