`MarketplacePluginDetailComponent` rendered the raw `installPath` from the installed-plugin registry, which leaks the user's home directory into the TUI when the plugin lives under `~/.omp/cache`. Route it through `shortenPath` so $HOME becomes `~` before the value reaches `Text`, matching the repo's TUI sanitization rules (per review on #1844).
723 lines
23 KiB
TypeScript
723 lines
23 KiB
TypeScript
/**
|
|
* Plugin settings UI components.
|
|
*
|
|
* Provides a hierarchical settings interface:
|
|
* - Plugin list (npm plugins + marketplace plugins)
|
|
* - npm plugin detail (enable/disable, features, config)
|
|
* - Marketplace plugin detail (enable/disable + read-only metadata)
|
|
* - Feature toggles
|
|
* - Config value editor
|
|
*/
|
|
import {
|
|
Container,
|
|
Input,
|
|
type SelectItem,
|
|
SelectList,
|
|
type SettingItem,
|
|
SettingsList,
|
|
Spacer,
|
|
Text,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
import { clearPluginRootsAndCaches, resolveOrDefaultProjectRegistryPath } from "../../discovery/helpers";
|
|
import { PluginManager } from "../../extensibility/plugins/manager";
|
|
import type { InstalledPluginSummary } from "../../extensibility/plugins/marketplace";
|
|
import {
|
|
getInstalledPluginsRegistryPath,
|
|
getMarketplacesCacheDir,
|
|
getMarketplacesRegistryPath,
|
|
getPluginsCacheDir,
|
|
MarketplaceManager,
|
|
} from "../../extensibility/plugins/marketplace";
|
|
import type { InstalledPlugin, PluginSettingSchema } from "../../extensibility/plugins/types";
|
|
import { getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
|
|
import { shortenPath } from "../../tools/render-utils";
|
|
import { DynamicBorder } from "./dynamic-border";
|
|
|
|
/**
|
|
* Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape.
|
|
*/
|
|
export function handleInputOrEscape(
|
|
data: string,
|
|
input: { handleInput(data: string): void },
|
|
onCancel: () => void,
|
|
): void {
|
|
if (data === "\x1b" || data === "\x1b\x1b") {
|
|
onCancel();
|
|
return;
|
|
}
|
|
input.handleInput(data);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Plugin List Component
|
|
// =============================================================================
|
|
|
|
/**
|
|
* One row in the unified plugin list. npm and marketplace plugins live in
|
|
* separate registries with different shapes, so a tagged union keeps both
|
|
* paths type-safe end-to-end (list rendering, value lookup, detail callback).
|
|
*/
|
|
export type PluginListEntry =
|
|
| { kind: "npm"; plugin: InstalledPlugin }
|
|
| { kind: "marketplace"; plugin: InstalledPluginSummary };
|
|
|
|
export interface PluginListCallbacks {
|
|
onNpmSelect: (plugin: InstalledPlugin) => void;
|
|
onMarketplaceSelect: (plugin: InstalledPluginSummary) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
/**
|
|
* True when the marketplace summary's first entry is not explicitly disabled.
|
|
* Mirrors the `/plugins list` convention: a missing `enabled` flag means enabled.
|
|
*/
|
|
function marketplaceEnabled(summary: InstalledPluginSummary): boolean {
|
|
return summary.entries[0]?.enabled !== false;
|
|
}
|
|
|
|
/**
|
|
* Stable SelectList value for a list entry. Combined with `findEntryByValue`
|
|
* this keeps lookup correct even when the same plugin id exists in both user
|
|
* and project scope (one of which is `shadowedBy: "project"`).
|
|
*/
|
|
function entryValue(entry: PluginListEntry): string {
|
|
if (entry.kind === "npm") return `npm:${entry.plugin.name}`;
|
|
return `mkt:${entry.plugin.scope}:${entry.plugin.id}`;
|
|
}
|
|
|
|
function findEntryByValue(entries: ReadonlyArray<PluginListEntry>, value: string): PluginListEntry | undefined {
|
|
return entries.find(e => entryValue(e) === value);
|
|
}
|
|
|
|
/**
|
|
* Shows installed plugins from both registries (npm + marketplace) with
|
|
* enable/disable status, scope tag, and shadow indicator. Selecting an entry
|
|
* fans out to the kind-specific detail callback.
|
|
*/
|
|
export class PluginListComponent extends Container {
|
|
readonly #selectList: SelectList;
|
|
|
|
constructor(
|
|
private readonly entries: ReadonlyArray<PluginListEntry>,
|
|
callbacks: PluginListCallbacks,
|
|
) {
|
|
super();
|
|
|
|
// Title
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", " Plugins")), 0, 0));
|
|
this.addChild(new Spacer(1));
|
|
|
|
if (entries.length === 0) {
|
|
this.addChild(new Text(theme.fg("muted", " No plugins installed"), 0, 0));
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Install npm plugins: omp plugin install <package>"), 0, 0));
|
|
this.addChild(
|
|
new Text(theme.fg("dim", " Install marketplace plugins: omp plugin install <name>@<marketplace>"), 0, 0),
|
|
);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new DynamicBorder());
|
|
|
|
// Empty list still handles Escape so the user can leave the panel.
|
|
this.#selectList = new SelectList([], 1, getSelectListTheme());
|
|
this.#selectList.onCancel = callbacks.onCancel;
|
|
return;
|
|
}
|
|
|
|
const items: SelectItem[] = entries.map(entry => this.#renderItem(entry));
|
|
|
|
// Marketplace plugin ids (`name@marketplace`) routinely run past the
|
|
// SelectList default primary column (32 chars). Widen the bound so the
|
|
// id remains readable; the description gets whatever width is left.
|
|
this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme(), {
|
|
minPrimaryColumnWidth: 24,
|
|
maxPrimaryColumnWidth: 64,
|
|
});
|
|
|
|
this.#selectList.onSelect = item => {
|
|
const found = findEntryByValue(this.entries, item.value);
|
|
if (!found) return;
|
|
if (found.kind === "npm") callbacks.onNpmSelect(found.plugin);
|
|
else callbacks.onMarketplaceSelect(found.plugin);
|
|
};
|
|
|
|
this.#selectList.onCancel = callbacks.onCancel;
|
|
|
|
this.addChild(this.#selectList);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to configure · Esc to go back"), 0, 0));
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
|
|
#renderItem(entry: PluginListEntry): SelectItem {
|
|
const kindBadge = theme.fg("dim", entry.kind === "npm" ? "[npm]" : "[marketplace]");
|
|
|
|
if (entry.kind === "npm") {
|
|
const p = entry.plugin;
|
|
const status = p.enabled
|
|
? theme.fg("success", theme.status.enabled)
|
|
: theme.fg("muted", theme.status.disabled);
|
|
const featureCount = p.manifest.features ? Object.keys(p.manifest.features).length : 0;
|
|
const enabledCount = p.enabledFeatures?.length ?? featureCount;
|
|
|
|
let details = `${kindBadge} ${theme.sep.dot} v${p.version}`;
|
|
if (featureCount > 0) {
|
|
details += ` ${theme.sep.dot} ${enabledCount}/${featureCount} features`;
|
|
}
|
|
|
|
return {
|
|
value: entryValue(entry),
|
|
label: `${status} ${p.name}`,
|
|
description: details,
|
|
};
|
|
}
|
|
|
|
const summary = entry.plugin;
|
|
const enabled = marketplaceEnabled(summary);
|
|
const status = enabled ? theme.fg("success", theme.status.enabled) : theme.fg("muted", theme.status.disabled);
|
|
const scopeTag = theme.fg("dim", `[${summary.scope}]`);
|
|
const shadowMarker = summary.shadowedBy ? ` ${theme.fg("warning", theme.status.shadowed)}` : "";
|
|
const version = summary.entries[0]?.version ?? "?";
|
|
|
|
let details = `${kindBadge} ${scopeTag} ${theme.sep.dot} v${version}`;
|
|
if (summary.shadowedBy) {
|
|
details += ` ${theme.sep.dot} shadowed by ${summary.shadowedBy}`;
|
|
}
|
|
|
|
return {
|
|
value: entryValue(entry),
|
|
label: `${status} ${summary.id}${shadowMarker}`,
|
|
description: details,
|
|
};
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.#selectList.handleInput(data);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Plugin Detail Component
|
|
// =============================================================================
|
|
|
|
export interface PluginDetailCallbacks {
|
|
onEnabledChange: (enabled: boolean) => void;
|
|
onFeatureChange: (feature: string, enabled: boolean) => void;
|
|
onConfigChange: (key: string, value: unknown) => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
/**
|
|
* Shows detail settings for a single plugin:
|
|
* - Enable/disable toggle
|
|
* - Feature toggles
|
|
* - Config settings
|
|
*/
|
|
export class PluginDetailComponent extends Container {
|
|
#settingsList!: SettingsList;
|
|
|
|
constructor(
|
|
private plugin: InstalledPlugin,
|
|
private readonly manager: PluginManager,
|
|
private readonly callbacks: PluginDetailCallbacks,
|
|
) {
|
|
super();
|
|
|
|
void this.#rebuild();
|
|
}
|
|
|
|
async #rebuild(): Promise<void> {
|
|
this.clear();
|
|
|
|
const plugin = this.plugin;
|
|
const manifest = plugin.manifest;
|
|
|
|
// Header
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", ` ${plugin.name}`)), 0, 0));
|
|
if (manifest.description) {
|
|
this.addChild(new Text(theme.fg("muted", ` ${manifest.description}`), 0, 0));
|
|
}
|
|
this.addChild(new Spacer(1));
|
|
|
|
const items: SettingItem[] = [];
|
|
|
|
// Enable/disable toggle
|
|
items.push({
|
|
id: "__enabled__",
|
|
label: "Enabled",
|
|
description: "Enable or disable this plugin",
|
|
currentValue: plugin.enabled ? "true" : "false",
|
|
values: ["true", "false"],
|
|
});
|
|
|
|
// Feature toggles
|
|
if (manifest.features && Object.keys(manifest.features).length > 0) {
|
|
const enabledSet = new Set(plugin.enabledFeatures ?? []);
|
|
const defaultFeatures = Object.entries(manifest.features)
|
|
.filter(([_, f]) => f.default)
|
|
.map(([name]) => name);
|
|
|
|
// If enabledFeatures is null, use defaults
|
|
const effectiveEnabled = plugin.enabledFeatures === null ? new Set(defaultFeatures) : enabledSet;
|
|
|
|
for (const [featName, feat] of Object.entries(manifest.features)) {
|
|
const isEnabled = effectiveEnabled.has(featName);
|
|
items.push({
|
|
id: `feature:${featName}`,
|
|
label: ` ${featName}`,
|
|
description: feat.description || `Enable ${featName} feature`,
|
|
currentValue: isEnabled ? "true" : "false",
|
|
values: ["true", "false"],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Config settings
|
|
if (manifest.settings && Object.keys(manifest.settings).length > 0) {
|
|
const settings = await this.manager.getPluginSettings(plugin.name);
|
|
|
|
for (const [key, schema] of Object.entries(manifest.settings)) {
|
|
const currentValue = settings[key] ?? schema.default;
|
|
const displayValue = schema.secret && currentValue ? "••••••••" : String(currentValue ?? "(not set)");
|
|
|
|
if (schema.type === "boolean") {
|
|
items.push({
|
|
id: `config:${key}`,
|
|
label: ` ${key}`,
|
|
description: schema.description || `Configure ${key}`,
|
|
currentValue: currentValue ? "true" : "false",
|
|
values: ["true", "false"],
|
|
});
|
|
} else if (schema.type === "enum") {
|
|
items.push({
|
|
id: `config:${key}`,
|
|
label: ` ${key}`,
|
|
description: schema.description || `Configure ${key}`,
|
|
currentValue: String(currentValue ?? schema.default ?? ""),
|
|
submenu: (cv, done) =>
|
|
new ConfigEnumSubmenu(
|
|
key,
|
|
schema.description || `Select value for ${key}`,
|
|
schema.values,
|
|
cv,
|
|
value => {
|
|
this.callbacks.onConfigChange(key, value);
|
|
done(value);
|
|
},
|
|
() => done(),
|
|
),
|
|
});
|
|
} else {
|
|
// string or number - show as submenu with input
|
|
items.push({
|
|
id: `config:${key}`,
|
|
label: ` ${key}`,
|
|
description: schema.description || `Configure ${key}`,
|
|
currentValue: displayValue,
|
|
submenu: (cv, done) =>
|
|
new ConfigInputSubmenu(
|
|
key,
|
|
schema,
|
|
cv === "(not set)" ? "" : cv,
|
|
value => {
|
|
const parsed = schema.type === "number" ? Number(value) : value;
|
|
this.callbacks.onConfigChange(key, parsed);
|
|
done(String(value));
|
|
},
|
|
() => done(),
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
this.#settingsList = new SettingsList(
|
|
items,
|
|
Math.min(items.length, 10),
|
|
getSettingsListTheme(),
|
|
(id, newValue) => {
|
|
if (id === "__enabled__") {
|
|
this.callbacks.onEnabledChange(newValue === "true");
|
|
this.plugin = { ...this.plugin, enabled: newValue === "true" };
|
|
} else if (id.startsWith("feature:")) {
|
|
const featName = id.slice(8);
|
|
this.callbacks.onFeatureChange(featName, newValue === "true");
|
|
// Update local state
|
|
const current = new Set(this.plugin.enabledFeatures ?? []);
|
|
if (newValue === "true") {
|
|
current.add(featName);
|
|
} else {
|
|
current.delete(featName);
|
|
}
|
|
this.plugin = { ...this.plugin, enabledFeatures: [...current] };
|
|
} else if (id.startsWith("config:")) {
|
|
const key = id.slice(7);
|
|
const schema = this.plugin.manifest.settings?.[key];
|
|
if (schema?.type === "boolean") {
|
|
this.callbacks.onConfigChange(key, newValue === "true");
|
|
}
|
|
}
|
|
},
|
|
this.callbacks.onBack,
|
|
);
|
|
|
|
this.addChild(this.#settingsList);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to edit · Esc to go back"), 0, 0));
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
if (!this.#settingsList) return;
|
|
this.#settingsList.handleInput(data);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Marketplace Plugin Detail Component
|
|
// =============================================================================
|
|
|
|
export interface MarketplacePluginDetailCallbacks {
|
|
onEnabledChange: (enabled: boolean) => void;
|
|
onBack: () => void;
|
|
}
|
|
|
|
/**
|
|
* Detail view for a marketplace plugin. Marketplace plugins do not declare
|
|
* features or settings, so the panel exposes a single enable/disable toggle
|
|
* plus the read-only metadata from the installed-plugins registry.
|
|
*/
|
|
export class MarketplacePluginDetailComponent extends Container {
|
|
#settingsList: SettingsList;
|
|
|
|
constructor(
|
|
private plugin: InstalledPluginSummary,
|
|
private readonly callbacks: MarketplacePluginDetailCallbacks,
|
|
) {
|
|
super();
|
|
|
|
const entry = plugin.entries[0];
|
|
const enabled = marketplaceEnabled(plugin);
|
|
|
|
// Header
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", ` ${plugin.id}`)), 0, 0));
|
|
|
|
const subtitleParts = [`[${plugin.scope}]`];
|
|
if (plugin.shadowedBy) subtitleParts.push(`${theme.status.shadowed} shadowed by ${plugin.shadowedBy}`);
|
|
this.addChild(new Text(theme.fg("muted", ` ${subtitleParts.join(" ")}`), 0, 0));
|
|
this.addChild(new Spacer(1));
|
|
|
|
const items: SettingItem[] = [
|
|
{
|
|
id: "__enabled__",
|
|
label: "Enabled",
|
|
description: "Enable or disable this marketplace plugin",
|
|
currentValue: enabled ? "true" : "false",
|
|
values: ["true", "false"],
|
|
},
|
|
];
|
|
|
|
this.#settingsList = new SettingsList(
|
|
items,
|
|
items.length,
|
|
getSettingsListTheme(),
|
|
(id, newValue) => {
|
|
if (id === "__enabled__") {
|
|
const next = newValue === "true";
|
|
this.callbacks.onEnabledChange(next);
|
|
this.plugin = {
|
|
...this.plugin,
|
|
entries: this.plugin.entries.map(e => ({ ...e, enabled: next })),
|
|
};
|
|
}
|
|
},
|
|
this.callbacks.onBack,
|
|
);
|
|
|
|
this.addChild(this.#settingsList);
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Read-only metadata. SettingsList rejects items without `values`/`submenu`,
|
|
// so we render the metadata as plain text rows beneath the toggle.
|
|
this.addChild(new Text(theme.fg("dim", ` version ${entry?.version ?? "(unknown)"}`), 0, 0));
|
|
this.addChild(new Text(theme.fg("dim", ` scope ${plugin.scope}`), 0, 0));
|
|
this.addChild(
|
|
new Text(
|
|
theme.fg("dim", ` install path ${entry?.installPath ? shortenPath(entry.installPath) : "(unknown)"}`),
|
|
0,
|
|
0,
|
|
),
|
|
);
|
|
this.addChild(new Text(theme.fg("dim", ` installed at ${entry?.installedAt ?? "(unknown)"}`), 0, 0));
|
|
this.addChild(new Text(theme.fg("dim", ` last updated ${entry?.lastUpdated ?? "(unknown)"}`), 0, 0));
|
|
if (entry?.gitCommitSha) {
|
|
this.addChild(new Text(theme.fg("dim", ` git sha ${entry.gitCommitSha}`), 0, 0));
|
|
}
|
|
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to toggle · Esc to go back"), 0, 0));
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.#settingsList.handleInput(data);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Config Submenus
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Submenu for enum config values.
|
|
*/
|
|
class ConfigEnumSubmenu extends Container {
|
|
#selectList: SelectList;
|
|
|
|
constructor(
|
|
key: string,
|
|
description: string,
|
|
values: string[],
|
|
currentValue: string,
|
|
onSelect: (value: string) => void,
|
|
onCancel: () => void,
|
|
) {
|
|
super();
|
|
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0));
|
|
if (description) {
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("muted", description), 0, 0));
|
|
}
|
|
this.addChild(new Spacer(1));
|
|
|
|
const items: SelectItem[] = values.map(v => ({ value: v, label: v }));
|
|
this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme());
|
|
|
|
const currentIndex = values.indexOf(currentValue);
|
|
if (currentIndex !== -1) {
|
|
this.#selectList.setSelectedIndex(currentIndex);
|
|
}
|
|
|
|
this.#selectList.onSelect = item => onSelect(item.value);
|
|
this.#selectList.onCancel = onCancel;
|
|
|
|
this.addChild(this.#selectList);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to cancel"), 0, 0));
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.#selectList.handleInput(data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submenu for string/number config values with text input.
|
|
*/
|
|
class ConfigInputSubmenu extends Container {
|
|
#input: Input;
|
|
|
|
constructor(
|
|
key: string,
|
|
schema: PluginSettingSchema,
|
|
currentValue: string,
|
|
private readonly onSubmit: (value: string) => void,
|
|
private readonly onCancel: () => void,
|
|
) {
|
|
super();
|
|
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0));
|
|
if (schema.description) {
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("muted", schema.description), 0, 0));
|
|
}
|
|
|
|
// Type hint
|
|
let hint = `Type: ${schema.type}`;
|
|
if (schema.type === "number") {
|
|
const numSchema = schema as { min?: number; max?: number };
|
|
if (numSchema.min !== undefined || numSchema.max !== undefined) {
|
|
hint += ` (${numSchema.min ?? ""}..${numSchema.max ?? ""})`;
|
|
}
|
|
}
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", hint), 0, 0));
|
|
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Input field
|
|
this.#input = new Input();
|
|
if (!schema.secret && currentValue) {
|
|
this.#input.setValue(currentValue);
|
|
}
|
|
|
|
this.#input.onSubmit = value => {
|
|
if (value.trim()) {
|
|
this.onSubmit(value);
|
|
} else {
|
|
this.onCancel();
|
|
}
|
|
};
|
|
|
|
this.addChild(this.#input);
|
|
this.addChild(new Spacer(1));
|
|
this.addChild(new Text(theme.fg("dim", " Enter to save · Esc to cancel"), 0, 0));
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
handleInputOrEscape(data, this.#input, this.onCancel);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main Plugin Settings Selector
|
|
// =============================================================================
|
|
|
|
export interface PluginSettingsCallbacks {
|
|
onClose: () => void;
|
|
onPluginChanged: () => void;
|
|
}
|
|
|
|
/** Component with handleInput method */
|
|
interface InputHandler {
|
|
handleInput(data: string): void;
|
|
}
|
|
|
|
/**
|
|
* Top-level plugin settings component.
|
|
* Manages navigation between plugin list and plugin detail views.
|
|
*/
|
|
export class PluginSettingsComponent extends Container {
|
|
#cwd: string;
|
|
#manager: PluginManager;
|
|
#viewComponent: (Container & InputHandler) | null = null;
|
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
|
|
#currentView: "list" | "npm-detail" | "marketplace-detail" = "list";
|
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
|
|
#currentPlugin: InstalledPlugin | null = null;
|
|
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management
|
|
#currentMarketplacePlugin: InstalledPluginSummary | null = null;
|
|
|
|
constructor(
|
|
cwd: string,
|
|
private readonly callbacks: PluginSettingsCallbacks,
|
|
) {
|
|
super();
|
|
this.#cwd = cwd;
|
|
this.#manager = new PluginManager(cwd);
|
|
this.#showPluginList();
|
|
}
|
|
|
|
async #buildMarketplaceManager(): Promise<MarketplaceManager> {
|
|
return new MarketplaceManager({
|
|
marketplacesRegistryPath: getMarketplacesRegistryPath(),
|
|
installedRegistryPath: getInstalledPluginsRegistryPath(),
|
|
projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath(this.#cwd),
|
|
marketplacesCacheDir: getMarketplacesCacheDir(),
|
|
pluginsCacheDir: getPluginsCacheDir(),
|
|
clearPluginRootsCache: clearPluginRootsAndCaches,
|
|
});
|
|
}
|
|
|
|
async #showPluginList(): Promise<void> {
|
|
this.#currentView = "list";
|
|
this.#currentPlugin = null;
|
|
this.#currentMarketplacePlugin = null;
|
|
this.clear();
|
|
|
|
// Surface marketplace failures without taking the npm path down with it —
|
|
// the registry can fail to load (corrupt JSON, missing project root) and
|
|
// the user still benefits from seeing their npm plugins.
|
|
const [npmPlugins, marketplacePlugins] = await Promise.all([
|
|
this.#manager.list(),
|
|
this.#buildMarketplaceManager()
|
|
.then(mgr => mgr.listInstalledPlugins())
|
|
.catch(err => {
|
|
logger.error("Settings → Plugins: failed to list marketplace plugins", {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
return [] as InstalledPluginSummary[];
|
|
}),
|
|
]);
|
|
|
|
const entries: PluginListEntry[] = [
|
|
...npmPlugins.map(plugin => ({ kind: "npm" as const, plugin })),
|
|
...marketplacePlugins.map(plugin => ({ kind: "marketplace" as const, plugin })),
|
|
];
|
|
|
|
this.#viewComponent = new PluginListComponent(entries, {
|
|
onNpmSelect: plugin => this.#showPluginDetail(plugin),
|
|
onMarketplaceSelect: plugin => this.#showMarketplaceDetail(plugin),
|
|
onCancel: () => this.callbacks.onClose(),
|
|
});
|
|
|
|
this.addChild(this.#viewComponent);
|
|
}
|
|
|
|
#showPluginDetail(plugin: InstalledPlugin): void {
|
|
this.#currentView = "npm-detail";
|
|
this.#currentPlugin = plugin;
|
|
this.#currentMarketplacePlugin = null;
|
|
this.clear();
|
|
|
|
this.#viewComponent = new PluginDetailComponent(plugin, this.#manager, {
|
|
onEnabledChange: async enabled => {
|
|
await this.#manager.setEnabled(plugin.name, enabled);
|
|
this.callbacks.onPluginChanged();
|
|
},
|
|
onFeatureChange: async (feature, enabled) => {
|
|
const current = new Set((await this.#manager.getEnabledFeatures(plugin.name)) ?? []);
|
|
if (enabled) {
|
|
current.add(feature);
|
|
} else {
|
|
current.delete(feature);
|
|
}
|
|
await this.#manager.setEnabledFeatures(plugin.name, [...current]);
|
|
this.callbacks.onPluginChanged();
|
|
},
|
|
onConfigChange: async (key, value) => {
|
|
await this.#manager.setPluginSetting(plugin.name, key, value);
|
|
this.callbacks.onPluginChanged();
|
|
},
|
|
onBack: () => this.#showPluginList(),
|
|
});
|
|
|
|
this.addChild(this.#viewComponent);
|
|
}
|
|
|
|
#showMarketplaceDetail(plugin: InstalledPluginSummary): void {
|
|
this.#currentView = "marketplace-detail";
|
|
this.#currentPlugin = null;
|
|
this.#currentMarketplacePlugin = plugin;
|
|
this.clear();
|
|
|
|
this.#viewComponent = new MarketplacePluginDetailComponent(plugin, {
|
|
onEnabledChange: async enabled => {
|
|
try {
|
|
const mgr = await this.#buildMarketplaceManager();
|
|
await mgr.setPluginEnabled(plugin.id, enabled, plugin.scope);
|
|
this.callbacks.onPluginChanged();
|
|
} catch (err) {
|
|
logger.error("Settings → Plugins: failed to toggle marketplace plugin", {
|
|
pluginId: plugin.id,
|
|
scope: plugin.scope,
|
|
enabled,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
},
|
|
onBack: () => this.#showPluginList(),
|
|
});
|
|
|
|
this.addChild(this.#viewComponent);
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
this.#viewComponent?.handleInput(data);
|
|
}
|
|
}
|