import { Checkbox, HStack, Label, ScrollContainer, Spacer, Theme, VStack } from '../../ui'; import { TabHeader } from '../TabHeader'; import type { PluginManager, PluginRecord } from './loader'; const NAME_WIDTH = 200; const VERSION_WIDTH = 70; const FILE_WIDTH = 200; /** What one plugin's row says about its state, beside the switch. */ export function statusText(record: PluginRecord): string { if (record.status === 'failed') return `Failed: ${record.error ?? 'unknown reason'}`; if (record.error) return `Switched off, but: ${record.error}`; return record.status === 'active' ? 'Active' : 'Off'; } /** The name to show: known once the plugin has been active in this session, the file name until then. */ export function displayName(record: PluginRecord): string { return record.meta?.name ?? '—'; } class PluginRow extends HStack { readonly switch: Checkbox; private readonly nameLabel: Label; private readonly version: Label; private readonly file: Label; private readonly status: Label; constructor( public record: PluginRecord, onToggle: (active: boolean) => void, ) { super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); this.minHeight = Theme.rowHeight + Theme.padding; this.switch = new Checkbox(record.status !== 'inactive'); this.switch.onChange = onToggle; this.add(this.switch); this.nameLabel = new Label('', { width: NAME_WIDTH, style: 'bodyBold' }); this.version = new Label('', { width: VERSION_WIDTH, style: 'muted' }); this.file = new Label('', { width: FILE_WIDTH, style: 'muted' }); this.status = new Label('', { style: 'small' }); this.add(this.nameLabel); this.add(this.version); this.add(this.file); this.add(this.status, 1); this.show(record); } show(record: PluginRecord): void { this.record = record; this.switch.setChecked(record.status !== 'inactive'); this.nameLabel.setText(displayName(record)); this.version.setText(record.meta?.version ?? ''); this.file.setText(record.file); this.status.setText(statusText(record)); this.status.setStyle(record.status === 'failed' ? 'warning' : 'small'); } } /** * The plugins (#114, #119) in the user management tab (#179): every file in `data/plugins/` with a * switch. Switching one on loads and activates it there and then, switching it off takes its tabs * and listeners away again – the switch is part of the open project, so it travels with it and undo * takes it back. * * A plugin that is off is never loaded, which is why its row has no name until it has run once: * reading a name out of it would mean running it. */ export class PluginsPane extends VStack { private readonly hint: Label; private readonly rows = new VStack(); private readonly scroll = new ScrollContainer(); private readonly empty: Label; private manager: PluginManager | null = null; private unsubscribe: (() => void) | null = null; constructor() { super(); const header = new TabHeader(); header.add(new Label('Plugins', { style: 'bodyBold' })); header.add(new Spacer(Theme.padding)); this.hint = new Label('', { style: 'small' }); header.add(this.hint, 1); this.add(header); this.empty = new Label('Loading…', { style: 'muted', padding: Theme.padding }); this.rows.add(this.empty); this.scroll.setContent(this.rows); this.add(this.scroll, 1); } setManager(manager: PluginManager): void { this.unsubscribe?.(); this.manager = manager; this.unsubscribe = manager.onChange(() => this.refresh()); this.refresh(); } /** The list could not even be read; there is nothing to show but why. */ showError(message: string): void { this.manager = null; this.empty.setText(message); this.refresh(); } /** The rows as drawn, and the switch to click – what the check scripts read. */ debugInfo(): { rows: { file: string; name: string; status: string; checked: boolean; text: string }[]; } { return { rows: this.rowWidgets().map((row) => ({ file: row.record.file, name: displayName(row.record), status: row.record.status, checked: row.switch.checked, text: statusText(row.record), })), }; } /** The switch of one plugin's row, for the check scripts; null when it has no row. */ switchOf(file: string): Checkbox | null { return this.rowWidgets().find((row) => row.record.file === file)?.switch ?? null; } private rowWidgets(): PluginRow[] { return this.rows.children.filter((child): child is PluginRow => child instanceof PluginRow); } private refresh(): void { const records = this.manager?.records ?? []; const rows = this.rowWidgets(); const sameList = rows.length === records.length && rows.every((row, i) => row.record.file === records[i]?.file); if (sameList) { rows.forEach((row, i) => row.show(records[i]!)); } else { for (const row of rows) row.destroy({ children: true }); for (const record of records) { const row = new PluginRow(record, (active) => void this.manager?.setActive(record.file, active)); this.rows.add(row); } } const nothing = records.length === 0; this.empty.visible = nothing; if (nothing && this.manager) this.empty.setText('No plugins in data/plugins'); this.hint.setText( nothing ? '' : 'Loaded from data/plugins. The switch belongs to this project and is an undo step.', ); this.scroll.relayoutContent(); this.relayout(); } }