Archived
feat(plugins): add workspace file helpers to labels
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add workspace file and render helpers to plugin workspace label callbacks so labels can load workspace-scoped metadata without hidden panels.
|
||||||
+54
-3
@@ -596,12 +596,18 @@ interface WorkspaceLabelContext {
|
|||||||
machine: PluginMachine;
|
machine: PluginMachine;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
state?: PluginRuntimeState;
|
state?: PluginRuntimeState;
|
||||||
|
files: {
|
||||||
|
readFile(path: string): Promise<FileContentResponse>;
|
||||||
|
};
|
||||||
|
host: {
|
||||||
|
requestRender(): void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`machine` and `workspace` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data.
|
`machine`, `workspace`, `files`, and `host` are documented as stable for label callbacks. Include `machine.id` in any label caches that depend on workspace data. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate label `visible` or `items` callbacks.
|
||||||
|
|
||||||
Items are sorted by `order` and then id. Return an empty array to render nothing.
|
Items are sorted by `order` and then id. Return an empty array to render nothing. Keep callbacks synchronous and lightweight; start async work from the callback, return cached items, then call `host.requestRender()` when the cache changes.
|
||||||
|
|
||||||
#### Text items
|
#### Text items
|
||||||
|
|
||||||
@@ -661,7 +667,7 @@ export default {
|
|||||||
|
|
||||||
## Reading workspace files
|
## Reading workspace files
|
||||||
|
|
||||||
Workspace panels can read files through the documented `files` helper. PI WEB binds this helper to the panel's machine and workspace, so it works the same for local and federated machines.
|
Workspace panels and workspace labels can read files through the documented `files` helper. PI WEB binds this helper to the callback's machine and workspace, so it works the same for local and federated machines.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
workspacePanels: [
|
workspacePanels: [
|
||||||
@@ -691,6 +697,51 @@ class MyEnvViewer extends HTMLElement {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Labels should use the same helper through a plugin-owned cache because `items()` itself must return synchronously:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const envCache = new Map();
|
||||||
|
|
||||||
|
function envKey(machine, workspace) {
|
||||||
|
return `${machine.id}:${workspace.id}:docker/development.be-go.local.env`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvLabel(context) {
|
||||||
|
const key = envKey(context.machine, context.workspace);
|
||||||
|
const cached = envCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const pending = { status: "loading", label: undefined };
|
||||||
|
envCache.set(key, pending);
|
||||||
|
context.files.readFile("docker/development.be-go.local.env")
|
||||||
|
.then((file) => {
|
||||||
|
pending.status = "ready";
|
||||||
|
pending.label = file.content.match(/^DEV_URL=(.+)$/m)?.[1];
|
||||||
|
context.host.requestRender();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
pending.status = "missing";
|
||||||
|
context.host.requestRender();
|
||||||
|
});
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceLabels: [
|
||||||
|
{
|
||||||
|
id: "dev-url",
|
||||||
|
items: (context) => {
|
||||||
|
const cached = loadEnvLabel(context);
|
||||||
|
return cached.label === undefined ? [] : [{
|
||||||
|
type: "link",
|
||||||
|
text: cached.label,
|
||||||
|
href: cached.label,
|
||||||
|
target: "_blank",
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.
|
The file response includes fields such as `path`, `content`, `truncated`, and `binary`. Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.
|
||||||
|
|
||||||
## Running workspace terminal commands
|
## Running workspace terminal commands
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
|
|||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
import { selectedMachineId } from "../controllers/types";
|
import { selectedMachineId } from "../controllers/types";
|
||||||
import { RealtimeSocket } from "../sessionSocket";
|
import { RealtimeSocket } from "../sessionSocket";
|
||||||
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
|
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||||
import { corePlugin } from "../plugins/core";
|
import { corePlugin } from "../plugins/core";
|
||||||
import { themePackPlugin } from "../plugins/themes";
|
import { themePackPlugin } from "../plugins/themes";
|
||||||
@@ -725,7 +725,7 @@ export class PiWebApp extends LitElement {
|
|||||||
private renderWorkspacePanel() {
|
private renderWorkspacePanel() {
|
||||||
const workspace = this.state.selectedWorkspace;
|
const workspace = this.state.selectedWorkspace;
|
||||||
const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace);
|
const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace);
|
||||||
const workspaceLabelItems = workspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, workspace);
|
const workspaceLabelItems = workspace === undefined ? [] : this.workspaceLabelItems(workspace);
|
||||||
const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined;
|
const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined;
|
||||||
return html`
|
return html`
|
||||||
<workspace-panel
|
<workspace-panel
|
||||||
@@ -802,7 +802,7 @@ export class PiWebApp extends LitElement {
|
|||||||
.projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")}
|
.projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")}
|
||||||
.workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")}
|
.workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")}
|
||||||
.sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")}
|
.sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")}
|
||||||
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
|
.workspaceLabelItems=${(workspace: Workspace) => this.workspaceLabelItems(workspace)}
|
||||||
.refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined}
|
.refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined}
|
||||||
.onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }}
|
.onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }}
|
||||||
.onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }}
|
.onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }}
|
||||||
@@ -901,6 +901,33 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private workspaceLabelItems(workspace: Workspace): WorkspaceLabelItem[] {
|
||||||
|
return this.plugins.getWorkspaceLabelItems(this.createWorkspaceLabelContext(workspace));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWorkspaceLabelContext(workspace: Workspace): WorkspaceLabelContext {
|
||||||
|
const machine = pluginMachineFromState(this.state);
|
||||||
|
return {
|
||||||
|
machine,
|
||||||
|
workspace,
|
||||||
|
state: this.state,
|
||||||
|
files: this.createWorkspaceFiles(workspace, machine.id),
|
||||||
|
host: this.createWorkspaceHost(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWorkspaceFiles(workspace: Workspace, machineId: string): WorkspaceFiles {
|
||||||
|
return {
|
||||||
|
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWorkspaceHost(): WorkspaceHost {
|
||||||
|
return {
|
||||||
|
requestRender: () => { this.requestUpdate(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
|
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
|
||||||
const machine = pluginMachineFromState(this.state);
|
const machine = pluginMachineFromState(this.state);
|
||||||
const machineId = machine.id;
|
const machineId = machine.id;
|
||||||
@@ -910,16 +937,12 @@ export class PiWebApp extends LitElement {
|
|||||||
machine,
|
machine,
|
||||||
workspace,
|
workspace,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
files: {
|
files: this.createWorkspaceFiles(workspace, machineId),
|
||||||
readFile: (path: string) => workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId),
|
|
||||||
},
|
|
||||||
terminal: {
|
terminal: {
|
||||||
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||||
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
|
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
|
||||||
},
|
},
|
||||||
host: {
|
host: this.createWorkspaceHost(),
|
||||||
requestRender: () => { this.requestUpdate(); },
|
|
||||||
},
|
|
||||||
piWebUnstable: { terminalCommandRuns },
|
piWebUnstable: { terminalCommandRuns },
|
||||||
fileTree: this.state.fileTree,
|
fileTree: this.state.fileTree,
|
||||||
expandedDirs: this.state.expandedDirs,
|
expandedDirs: this.state.expandedDirs,
|
||||||
@@ -1353,7 +1376,7 @@ export class PiWebApp extends LitElement {
|
|||||||
${state.selectedSession ? html`
|
${state.selectedSession ? html`
|
||||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .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>
|
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .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} .machineId=${selectedMachineId(state)} .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.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .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.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
|
||||||
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
|
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.workspaceLabelItems(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.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.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}
|
${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,13 @@
|
|||||||
import { html } from "lit";
|
import { html } from "lit";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { SessionInfo, Workspace } from "../api";
|
import type { FileContentResponse, SessionInfo, Workspace } from "../api";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
||||||
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
||||||
import { corePlugin } from "./core";
|
import { corePlugin } from "./core";
|
||||||
import { PluginRegistry } from "./registry";
|
import { PluginRegistry } from "./registry";
|
||||||
import { themePackPlugin } from "./themes";
|
import { themePackPlugin } from "./themes";
|
||||||
import type { PluginRuntimeContext, ThemeTokens, WorkspacePanelContext } from "./types";
|
import type { PluginRuntimeContext, ThemeTokens, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "./types";
|
||||||
|
|
||||||
function createContext(statePatch: Partial<AppState> = {}) {
|
function createContext(statePatch: Partial<AppState> = {}) {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -286,12 +286,45 @@ describe("PluginRegistry", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([
|
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([
|
||||||
{ type: "link", text: "web", href: "http://localhost:5173" },
|
{ type: "link", text: "web", href: "http://localhost:5173" },
|
||||||
{ type: "text", text: "last" },
|
{ type: "text", text: "last" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes workspace label file and host helpers to callbacks", () => {
|
||||||
|
const registry = new PluginRegistry();
|
||||||
|
const workspace = testWorkspace();
|
||||||
|
const readFile = vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent("docker/development.be-go.local.env")));
|
||||||
|
const requestRender = vi.fn<WorkspaceHost["requestRender"]>();
|
||||||
|
const visible = vi.fn<(context: WorkspaceLabelContext) => boolean>(() => true);
|
||||||
|
const items = vi.fn<(context: WorkspaceLabelContext) => WorkspaceLabelItem[]>((context) => {
|
||||||
|
void context.files.readFile("docker/development.be-go.local.env");
|
||||||
|
context.host.requestRender();
|
||||||
|
return [{ type: "text", text: context.machine.id }];
|
||||||
|
});
|
||||||
|
const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } });
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
id: "example",
|
||||||
|
plugin: {
|
||||||
|
apiVersion: 1,
|
||||||
|
name: "Example",
|
||||||
|
activate: () => ({
|
||||||
|
contributions: {
|
||||||
|
workspaceLabels: [{ id: "env", visible, items }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.getWorkspaceLabelItems(context)).toEqual([{ type: "text", text: "remote-1" }]);
|
||||||
|
expect(visible).toHaveBeenCalledWith(context);
|
||||||
|
expect(items).toHaveBeenCalledWith(context);
|
||||||
|
expect(readFile).toHaveBeenCalledWith("docker/development.be-go.local.env");
|
||||||
|
expect(requestRender).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("only exposes machine-scoped plugin contributions for their machine", () => {
|
it("only exposes machine-scoped plugin contributions for their machine", () => {
|
||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
const pluginId = machineScopedPluginId("remote-1", "project-tools");
|
const pluginId = machineScopedPluginId("remote-1", "project-tools");
|
||||||
@@ -321,8 +354,8 @@ describe("PluginRegistry", () => {
|
|||||||
expect(panel?.visible?.(createWorkspacePanelContext("local"))).toBe(false);
|
expect(panel?.visible?.(createWorkspacePanelContext("local"))).toBe(false);
|
||||||
expect(panel?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
expect(panel?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
||||||
|
|
||||||
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([]);
|
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("local", workspace))).toEqual([]);
|
||||||
expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "remote" }]);
|
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "remote" }]);
|
||||||
expect(registry.getThemes()).toEqual([]);
|
expect(registry.getThemes()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -371,7 +404,7 @@ describe("PluginRegistry", () => {
|
|||||||
const panels = registry.getWorkspacePanels();
|
const panels = registry.getWorkspacePanels();
|
||||||
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
|
expect(panels.find((panel) => panel.id === `${remotePluginId}:workspace.remote`)?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(false);
|
||||||
expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
expect(panels.find((panel) => panel.id === "shared-tools:workspace.gateway")?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
|
||||||
expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "gateway" }]);
|
expect(registry.getWorkspaceLabelItems(createWorkspaceLabelContext("remote-1", workspace))).toEqual([{ type: "text", text: "gateway" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not activate remote duplicates when the gateway plugin is already registered", () => {
|
it("does not activate remote duplicates when the gateway plugin is already registered", () => {
|
||||||
@@ -394,6 +427,18 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
|
|||||||
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
|
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial<Pick<WorkspaceLabelContext, "files" | "host">> = {}): WorkspaceLabelContext {
|
||||||
|
const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn<WorkspaceFiles["readFile"]>(() => Promise.resolve(testFileContent())) };
|
||||||
|
const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn<WorkspaceHost["requestRender"]>() };
|
||||||
|
return {
|
||||||
|
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
|
||||||
|
workspace,
|
||||||
|
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
|
||||||
|
files,
|
||||||
|
host,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
|
function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
|
||||||
const workspace = testWorkspace();
|
const workspace = testWorkspace();
|
||||||
return {
|
return {
|
||||||
@@ -425,6 +470,18 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testFileContent(path = "README.md"): FileContentResponse {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
encoding: "utf8",
|
||||||
|
size: 0,
|
||||||
|
modifiedAt: "2026-05-20T00:00:00.000Z",
|
||||||
|
content: "",
|
||||||
|
truncated: false,
|
||||||
|
binary: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function testMachine(id: string) {
|
function testMachine(id: string) {
|
||||||
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
|
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { html, svg } from "lit";
|
import { html, svg } from "lit";
|
||||||
import type { AppState } from "../appState";
|
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContext, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
|
||||||
import type { Workspace } from "../api";
|
|
||||||
import type { PiWebPluginRegistration, PluginAction, PluginMachine, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContext, WorkspacePanelContribution } from "./types";
|
|
||||||
|
|
||||||
const idPattern = /^[a-z][a-z0-9.-]*$/u;
|
const idPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||||
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
|
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
|
||||||
@@ -79,8 +77,7 @@ export class PluginRegistry {
|
|||||||
return [...this.themePairs].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.name.localeCompare(right.name));
|
return [...this.themePairs].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.name.localeCompare(right.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] {
|
getWorkspaceLabelItems(context: WorkspaceLabelContext): WorkspaceLabelItem[] {
|
||||||
const context = { machine: pluginMachineFromState(state), state, workspace };
|
|
||||||
return [...this.workspaceLabels]
|
return [...this.workspaceLabels]
|
||||||
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
|
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
|
||||||
.flatMap((contribution) => {
|
.flatMap((contribution) => {
|
||||||
@@ -197,9 +194,3 @@ function isHiddenByGatewayPlugin(sourcePluginId: string | undefined, gatewayPlug
|
|||||||
function runtimeContextMachineId(context: PluginRuntimeContext): string {
|
function runtimeContextMachineId(context: PluginRuntimeContext): string {
|
||||||
return context.state.selectedMachine?.id ?? "local";
|
return context.state.selectedMachine?.id ?? "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): PluginMachine {
|
|
||||||
const machine = state.selectedMachine;
|
|
||||||
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
|
||||||
return { id: "local", name: "local", kind: "local" };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,10 +47,26 @@ export interface PluginMachine {
|
|||||||
kind: Machine["kind"];
|
kind: Machine["kind"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspacePanelFiles {
|
export interface WorkspaceFiles {
|
||||||
readFile(path: string): Promise<FileContentResponse>;
|
readFile(path: string): Promise<FileContentResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspacePanelFiles = WorkspaceFiles;
|
||||||
|
|
||||||
|
export interface WorkspaceHost {
|
||||||
|
requestRender(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspacePanelHost = WorkspaceHost;
|
||||||
|
|
||||||
|
export interface WorkspaceContext {
|
||||||
|
machine: PluginMachine;
|
||||||
|
workspace: Workspace;
|
||||||
|
state: AppState;
|
||||||
|
files: WorkspaceFiles;
|
||||||
|
host: WorkspaceHost;
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceTerminalCommandInput = Omit<RunTerminalCommandInput, "workspace">;
|
export type WorkspaceTerminalCommandInput = Omit<RunTerminalCommandInput, "workspace">;
|
||||||
|
|
||||||
export interface WorkspacePanelTerminal {
|
export interface WorkspacePanelTerminal {
|
||||||
@@ -58,10 +74,6 @@ export interface WorkspacePanelTerminal {
|
|||||||
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
|
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspacePanelHost {
|
|
||||||
requestRender(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PiWebUnstableRuntimeContext {
|
export interface PiWebUnstableRuntimeContext {
|
||||||
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
|
terminalCommandRuns: TerminalCommandRunsInternalRuntime;
|
||||||
openSettings?: (section?: SettingsSection) => void;
|
openSettings?: (section?: SettingsSection) => void;
|
||||||
@@ -117,13 +129,8 @@ export interface QualifiedPluginAction extends AppAction {
|
|||||||
machineId?: string;
|
machineId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspacePanelContext {
|
export interface WorkspacePanelContext extends WorkspaceContext {
|
||||||
machine: PluginMachine;
|
|
||||||
workspace: Workspace;
|
|
||||||
state: AppState;
|
|
||||||
files: WorkspacePanelFiles;
|
|
||||||
terminal: WorkspacePanelTerminal;
|
terminal: WorkspacePanelTerminal;
|
||||||
host: WorkspacePanelHost;
|
|
||||||
piWebUnstable?: Pick<PiWebUnstableRuntimeContext, "terminalCommandRuns">;
|
piWebUnstable?: Pick<PiWebUnstableRuntimeContext, "terminalCommandRuns">;
|
||||||
fileTree: FileTreeEntry[];
|
fileTree: FileTreeEntry[];
|
||||||
expandedDirs: Record<string, FileTreeEntry[]>;
|
expandedDirs: Record<string, FileTreeEntry[]>;
|
||||||
@@ -165,10 +172,12 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr
|
|||||||
machineId?: string;
|
machineId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceLabelContext {
|
export interface WorkspaceLabelContext extends WorkspaceContext {
|
||||||
machine: PluginMachine;
|
machine: PluginMachine;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
state: AppState;
|
state: AppState;
|
||||||
|
files: WorkspaceFiles;
|
||||||
|
host: WorkspaceHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem;
|
export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem;
|
||||||
|
|||||||
+21
-12
@@ -108,10 +108,26 @@ export interface Workspace {
|
|||||||
isGitWorktree: boolean;
|
isGitWorktree: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspacePanelFiles {
|
export interface WorkspaceFiles {
|
||||||
readFile(path: string): Promise<FileContentResponse>;
|
readFile(path: string): Promise<FileContentResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspacePanelFiles = WorkspaceFiles;
|
||||||
|
|
||||||
|
export interface WorkspaceHost {
|
||||||
|
requestRender(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspacePanelHost = WorkspaceHost;
|
||||||
|
|
||||||
|
export interface WorkspaceContext {
|
||||||
|
machine: PluginMachine;
|
||||||
|
workspace: Workspace;
|
||||||
|
state?: PluginRuntimeState;
|
||||||
|
files: WorkspaceFiles;
|
||||||
|
host: WorkspaceHost;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceTerminalCommandInput {
|
export interface WorkspaceTerminalCommandInput {
|
||||||
title: string;
|
title: string;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -124,17 +140,8 @@ export interface WorkspacePanelTerminal {
|
|||||||
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
|
runCommand(input: WorkspaceTerminalCommandInput): Promise<TerminalCommandRunHandle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspacePanelHost {
|
export interface WorkspacePanelContext extends WorkspaceContext {
|
||||||
requestRender(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspacePanelContext {
|
|
||||||
machine: PluginMachine;
|
|
||||||
workspace: Workspace;
|
|
||||||
state?: PluginRuntimeState;
|
|
||||||
files: WorkspacePanelFiles;
|
|
||||||
terminal: WorkspacePanelTerminal;
|
terminal: WorkspacePanelTerminal;
|
||||||
host: WorkspacePanelHost;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspacePanelIcon = TemplateResult;
|
export type WorkspacePanelIcon = TemplateResult;
|
||||||
@@ -149,10 +156,12 @@ export interface WorkspacePanelContribution {
|
|||||||
render: (context: WorkspacePanelContext) => TemplateResult;
|
render: (context: WorkspacePanelContext) => TemplateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceLabelContext {
|
export interface WorkspaceLabelContext extends WorkspaceContext {
|
||||||
machine: PluginMachine;
|
machine: PluginMachine;
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
state?: PluginRuntimeState;
|
state?: PluginRuntimeState;
|
||||||
|
files: WorkspaceFiles;
|
||||||
|
host: WorkspaceHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem;
|
export type WorkspaceLabelItem = WorkspaceLabelTextItem | WorkspaceLabelLinkItem | WorkspaceLabelRenderItem;
|
||||||
|
|||||||
Reference in New Issue
Block a user