From 221035aba679a20d675b801ac8ab14968d1d67ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 10:06:14 +0000 Subject: [PATCH 1/7] feat(plugins): Add TypeScript controls library for plugin UIs Add a UI controls library that mirrors the Rust control patterns, eliminating manual text construction and byte offset calculation when building plugin interfaces. New files: - controls.ts: ButtonControl, ListControl, GroupedListControl, FocusManager, TextInputControl, ToggleButton, Separator, Label - vbuffer.ts: VirtualBufferBuilder for automatic UTF-8 handling The VirtualBufferBuilder handles character-to-byte conversion automatically, so plugins can work with character offsets instead of manually tracking UTF-8 byte positions. This is Part 2 of the Unified UI Framework plan - the TypeScript equivalent of the Rust layout utilities (point_in_rect, FocusManager, MenuLayout, TabLayout) that were already implemented. Next step: Migrate pkg.ts to use the new controls library. https://claude.ai/code/session_01Kt49PHNWUZjs4TNk8zxZZ1 --- crates/fresh-editor/plugins/lib/controls.ts | 785 ++++++++++++++++++++ crates/fresh-editor/plugins/lib/index.ts | 46 +- crates/fresh-editor/plugins/lib/vbuffer.ts | 374 ++++++++++ docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md | 18 +- 4 files changed, 1215 insertions(+), 8 deletions(-) create mode 100644 crates/fresh-editor/plugins/lib/controls.ts create mode 100644 crates/fresh-editor/plugins/lib/vbuffer.ts diff --git a/crates/fresh-editor/plugins/lib/controls.ts b/crates/fresh-editor/plugins/lib/controls.ts new file mode 100644 index 000000000..e118240d8 --- /dev/null +++ b/crates/fresh-editor/plugins/lib/controls.ts @@ -0,0 +1,785 @@ +/// + +/** + * UI Controls Library for Fresh Editor Plugins + * + * Provides TypeScript controls that mirror the Rust control patterns used in + * the editor's Settings UI. This eliminates manual text construction and + * byte offset calculation when building plugin UIs. + * + * @example + * ```typescript + * import { ButtonControl, ListControl, FocusManager, FocusState } from "./lib/controls.ts"; + * + * const button = new ButtonControl("Install", FocusState.Focused); + * const { text, styles } = button.render(); + * ``` + */ + +import type { RGB } from "./types.ts"; + +// ============================================================================= +// Focus State +// ============================================================================= + +/** + * Focus state for controls - mirrors FocusState in Rust controls + */ +export enum FocusState { + Normal = "normal", + Focused = "focused", + Hovered = "hovered", + Disabled = "disabled", +} + +// ============================================================================= +// Style Types +// ============================================================================= + +/** + * Style range for text coloring + * + * Uses character offsets (not bytes) - the VirtualBufferBuilder handles + * UTF-8 conversion automatically. + */ +export interface StyleRange { + /** Start character offset (0-indexed) */ + start: number; + /** End character offset (exclusive) */ + end: number; + /** Foreground color - theme key (e.g., "syntax.keyword") or RGB tuple */ + fg?: string | RGB; + /** Background color - theme key or RGB tuple */ + bg?: string | RGB; + /** Bold text */ + bold?: boolean; + /** Underline text */ + underline?: boolean; +} + +/** + * Rendered output from a control + */ +export interface ControlOutput { + /** The rendered text content */ + text: string; + /** Style ranges to apply */ + styles: StyleRange[]; +} + +// ============================================================================= +// Button Control +// ============================================================================= + +/** + * Button control - mirrors controls/button in Rust + * + * Renders a button with focus indicators (brackets when focused). + * + * @example + * ```typescript + * const button = new ButtonControl("Save", FocusState.Focused); + * const { text, styles } = button.render(); + * // text: "[ Save ]" + * ``` + */ +export class ButtonControl { + constructor( + /** Button label text */ + public label: string, + /** Current focus state */ + public focus: FocusState = FocusState.Normal, + /** Theme color for focused state */ + public focusedBg: string | RGB = "ui.menu_active_bg", + /** Theme color for focused foreground */ + public focusedFg: string | RGB = "ui.menu_active_fg", + /** Theme color for normal state */ + public normalFg: string | RGB = "ui.fg", + ) {} + + /** + * Render the button text with focus indicators + */ + render(): ControlOutput { + const focused = this.focus === FocusState.Focused; + const hovered = this.focus === FocusState.Hovered; + const disabled = this.focus === FocusState.Disabled; + + // Show brackets when focused, spaces otherwise (to maintain alignment) + const left = focused ? "[" : " "; + const right = focused ? "]" : " "; + const text = `${left} ${this.label} ${right}`; + + const styles: StyleRange[] = []; + + if (disabled) { + styles.push({ + start: 0, + end: text.length, + fg: "ui.fg_muted", + }); + } else if (focused) { + styles.push({ + start: 0, + end: text.length, + fg: this.focusedFg, + bg: this.focusedBg, + }); + } else if (hovered) { + styles.push({ + start: 0, + end: text.length, + fg: this.focusedFg, + bg: this.focusedBg, + }); + } + + return { text, styles }; + } + + /** + * Get the rendered width of this button + */ + get width(): number { + return this.label.length + 4; // "[ " + label + " ]" + } +} + +// ============================================================================= +// Toggle Button Control +// ============================================================================= + +/** + * Toggle button - a button that shows on/off state + * + * @example + * ```typescript + * const toggle = new ToggleButton("Dark Mode", true, FocusState.Normal); + * const { text } = toggle.render(); + * // text: " Dark Mode [ON] " + * ``` + */ +export class ToggleButton { + constructor( + public label: string, + public isOn: boolean = false, + public focus: FocusState = FocusState.Normal, + ) {} + + render(): ControlOutput { + const focused = this.focus === FocusState.Focused; + const indicator = this.isOn ? "[ON]" : "[OFF]"; + const left = focused ? "[" : " "; + const right = focused ? "]" : " "; + const text = `${left} ${this.label} ${indicator} ${right}`; + + const styles: StyleRange[] = []; + if (focused) { + styles.push({ + start: 0, + end: text.length, + fg: "ui.menu_active_fg", + bg: "ui.menu_active_bg", + }); + } + + return { text, styles }; + } +} + +// ============================================================================= +// List Control +// ============================================================================= + +/** + * List item renderer function type + */ +export type ItemRenderer = (item: T, selected: boolean, index: number) => string; + +/** + * Selectable list control - mirrors Settings item list behavior + * + * Handles selection, scrolling, and rendering with selection indicators. + * + * @example + * ```typescript + * interface Package { name: string; version: string; } + * + * const list = new ListControl( + * packages, + * (pkg, selected) => `${pkg.name} v${pkg.version}`, + * { maxVisible: 10, selectionPrefix: ">" } + * ); + * + * list.selectNext(); + * const { text, styles, selectedLine } = list.render(); + * ``` + */ +export class ListControl { + /** Currently selected index */ + public selectedIndex: number = 0; + /** Current scroll offset */ + public scrollOffset: number = 0; + + private _maxVisible: number; + private _selectionPrefix: string; + private _emptyPrefix: string; + private _selectedFg: string | RGB; + private _selectedBg: string | RGB; + + constructor( + /** Items to display */ + public items: T[], + /** Function to render each item to a string */ + public renderItem: ItemRenderer, + options: { + /** Maximum visible items before scrolling (default: 10) */ + maxVisible?: number; + /** Prefix for selected item (default: "▸ ") */ + selectionPrefix?: string; + /** Prefix for non-selected items (default: " ") */ + emptyPrefix?: string; + /** Selected item foreground color */ + selectedFg?: string | RGB; + /** Selected item background color */ + selectedBg?: string | RGB; + } = {} + ) { + this._maxVisible = options.maxVisible ?? 10; + this._selectionPrefix = options.selectionPrefix ?? "▸ "; + this._emptyPrefix = options.emptyPrefix ?? " "; + this._selectedFg = options.selectedFg ?? "ui.menu_active_fg"; + this._selectedBg = options.selectedBg ?? "ui.menu_active_bg"; + } + + /** + * Select the next item + */ + selectNext(): void { + if (this.items.length === 0) return; + this.selectedIndex = Math.min(this.selectedIndex + 1, this.items.length - 1); + this.ensureVisible(); + } + + /** + * Select the previous item + */ + selectPrev(): void { + if (this.items.length === 0) return; + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + this.ensureVisible(); + } + + /** + * Select first item + */ + selectFirst(): void { + this.selectedIndex = 0; + this.ensureVisible(); + } + + /** + * Select last item + */ + selectLast(): void { + if (this.items.length === 0) return; + this.selectedIndex = this.items.length - 1; + this.ensureVisible(); + } + + /** + * Get the currently selected item + */ + selectedItem(): T | undefined { + return this.items[this.selectedIndex]; + } + + /** + * Update items and reset selection if needed + */ + setItems(items: T[]): void { + this.items = items; + if (this.selectedIndex >= items.length) { + this.selectedIndex = Math.max(0, items.length - 1); + } + this.ensureVisible(); + } + + /** + * Ensure the selected item is visible by adjusting scroll offset + */ + private ensureVisible(): void { + if (this.selectedIndex < this.scrollOffset) { + this.scrollOffset = this.selectedIndex; + } else if (this.selectedIndex >= this.scrollOffset + this._maxVisible) { + this.scrollOffset = this.selectedIndex - this._maxVisible + 1; + } + } + + /** + * Render the list + */ + render(): ControlOutput & { selectedLine: number } { + const lines: string[] = []; + const styles: StyleRange[] = []; + let charOffset = 0; + + const visibleItems = this.items.slice( + this.scrollOffset, + this.scrollOffset + this._maxVisible + ); + + for (let i = 0; i < visibleItems.length; i++) { + const actualIndex = this.scrollOffset + i; + const selected = actualIndex === this.selectedIndex; + const prefix = selected ? this._selectionPrefix : this._emptyPrefix; + const line = prefix + this.renderItem(visibleItems[i], selected, actualIndex); + lines.push(line); + + if (selected) { + styles.push({ + start: charOffset, + end: charOffset + line.length, + fg: this._selectedFg, + bg: this._selectedBg, + }); + } + charOffset += line.length + 1; // +1 for \n + } + + return { + text: lines.join("\n"), + styles, + selectedLine: this.selectedIndex - this.scrollOffset, + }; + } + + /** + * Check if there are more items above the visible area + */ + get hasScrollUp(): boolean { + return this.scrollOffset > 0; + } + + /** + * Check if there are more items below the visible area + */ + get hasScrollDown(): boolean { + return this.scrollOffset + this._maxVisible < this.items.length; + } + + /** + * Get the number of items + */ + get length(): number { + return this.items.length; + } + + /** + * Check if the list is empty + */ + get isEmpty(): boolean { + return this.items.length === 0; + } +} + +// ============================================================================= +// Grouped List Control +// ============================================================================= + +/** + * A group of items with a title + */ +export interface ListGroup { + /** Group title (e.g., "INSTALLED", "AVAILABLE") */ + title: string; + /** Items in this group */ + items: T[]; +} + +/** + * List control with grouped sections + * + * Useful for showing categorized lists like "Installed" and "Available" packages. + * + * @example + * ```typescript + * const groupedList = new GroupedListControl( + * [ + * { title: "INSTALLED (3)", items: installedPackages }, + * { title: "AVAILABLE (10)", items: availablePackages }, + * ], + * (pkg, selected) => `${pkg.name} v${pkg.version}` + * ); + * ``` + */ +export class GroupedListControl { + public selectedIndex: number = 0; + public scrollOffset: number = 0; + + private _maxVisible: number; + private _selectionPrefix: string; + private _emptyPrefix: string; + + constructor( + public groups: ListGroup[], + public renderItem: ItemRenderer, + options: { + maxVisible?: number; + selectionPrefix?: string; + emptyPrefix?: string; + } = {} + ) { + this._maxVisible = options.maxVisible ?? 10; + this._selectionPrefix = options.selectionPrefix ?? "▸ "; + this._emptyPrefix = options.emptyPrefix ?? " "; + } + + /** + * Get all items flattened + */ + private get allItems(): T[] { + return this.groups.flatMap(g => g.items); + } + + /** + * Get total item count + */ + get length(): number { + return this.allItems.length; + } + + selectNext(): void { + const total = this.length; + if (total === 0) return; + this.selectedIndex = Math.min(this.selectedIndex + 1, total - 1); + } + + selectPrev(): void { + if (this.length === 0) return; + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + } + + selectedItem(): T | undefined { + return this.allItems[this.selectedIndex]; + } + + render(): ControlOutput { + const lines: string[] = []; + const styles: StyleRange[] = []; + let charOffset = 0; + let itemIndex = 0; + + for (const group of this.groups) { + // Group title + if (lines.length > 0) { + lines.push(""); // Blank line between groups + charOffset += 1; + } + + const titleLine = group.title; + lines.push(titleLine); + styles.push({ + start: charOffset, + end: charOffset + titleLine.length, + fg: "syntax.keyword", + bold: true, + }); + charOffset += titleLine.length + 1; + + // Group items + for (const item of group.items) { + const selected = itemIndex === this.selectedIndex; + const prefix = selected ? this._selectionPrefix : this._emptyPrefix; + const line = prefix + this.renderItem(item, selected, itemIndex); + lines.push(line); + + if (selected) { + styles.push({ + start: charOffset, + end: charOffset + line.length, + fg: "ui.menu_active_fg", + bg: "ui.menu_active_bg", + }); + } + + charOffset += line.length + 1; + itemIndex++; + } + } + + return { + text: lines.join("\n"), + styles, + }; + } +} + +// ============================================================================= +// Focus Manager +// ============================================================================= + +/** + * Manages focus cycling through a list of elements + * + * This mirrors FocusManager from Rust (src/view/ui/focus.rs). + * Use it to handle Tab-order navigation between UI regions. + * + * @example + * ```typescript + * type Panel = "search" | "filters" | "list" | "details"; + * const focus = new FocusManager(["search", "filters", "list", "details"]); + * + * focus.current(); // "search" + * focus.focusNext(); // "filters" + * focus.focusNext(); // "list" + * focus.isFocused("list"); // true + * ``` + */ +export class FocusManager { + private currentIndex: number = 0; + + constructor( + /** Ordered list of focusable elements */ + public elements: T[] + ) {} + + /** + * Get the currently focused element + */ + current(): T | undefined { + return this.elements[this.currentIndex]; + } + + /** + * Get the current index + */ + index(): number { + return this.currentIndex; + } + + /** + * Move focus to the next element (wraps around) + */ + focusNext(): T | undefined { + if (this.elements.length === 0) return undefined; + this.currentIndex = (this.currentIndex + 1) % this.elements.length; + return this.current(); + } + + /** + * Move focus to the previous element (wraps around) + */ + focusPrev(): T | undefined { + if (this.elements.length === 0) return undefined; + this.currentIndex = (this.currentIndex + this.elements.length - 1) % this.elements.length; + return this.current(); + } + + /** + * Check if an element is currently focused + */ + isFocused(element: T): boolean { + return this.elements[this.currentIndex] === element; + } + + /** + * Set focus to a specific element + * @returns true if element was found and focused + */ + focus(element: T): boolean { + const idx = this.elements.indexOf(element); + if (idx >= 0) { + this.currentIndex = idx; + return true; + } + return false; + } + + /** + * Set focus by index + * @returns true if index was valid + */ + focusByIndex(index: number): boolean { + if (index >= 0 && index < this.elements.length) { + this.currentIndex = index; + return true; + } + return false; + } + + /** + * Get the number of elements + */ + get length(): number { + return this.elements.length; + } + + /** + * Check if empty + */ + get isEmpty(): boolean { + return this.elements.length === 0; + } +} + +// ============================================================================= +// Text Input Control +// ============================================================================= + +/** + * Text input control for search boxes and text entry + * + * @example + * ```typescript + * const search = new TextInputControl("Search:", 30); + * search.value = "query"; + * const { text, styles } = search.render(FocusState.Focused); + * // text: "Search: [query ]" + * ``` + */ +export class TextInputControl { + /** Current input value */ + public value: string = ""; + /** Cursor position */ + public cursor: number = 0; + + constructor( + /** Label shown before the input */ + public label: string, + /** Width of the input field */ + public width: number = 20, + ) {} + + /** + * Insert text at cursor position + */ + insert(text: string): void { + this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.cursor += text.length; + } + + /** + * Delete character before cursor + */ + backspace(): void { + if (this.cursor > 0) { + this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor); + this.cursor--; + } + } + + /** + * Delete character at cursor + */ + delete(): void { + if (this.cursor < this.value.length) { + this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1); + } + } + + /** + * Clear the input + */ + clear(): void { + this.value = ""; + this.cursor = 0; + } + + /** + * Render the input field + */ + render(focus: FocusState = FocusState.Normal): ControlOutput { + const focused = focus === FocusState.Focused; + + // Truncate or pad the display value + let display = this.value; + if (display.length > this.width - 1) { + display = display.slice(0, this.width - 2) + "..."; + } else { + display = display.padEnd(this.width); + } + + const left = focused ? "[" : " "; + const right = focused ? "]" : " "; + const text = `${this.label}${left}${display}${right}`; + + const styles: StyleRange[] = []; + const inputStart = this.label.length; + const inputEnd = text.length; + + if (focused) { + styles.push({ + start: inputStart, + end: inputEnd, + fg: "ui.menu_active_fg", + bg: "ui.menu_active_bg", + }); + } else { + styles.push({ + start: inputStart, + end: inputEnd, + fg: "ui.fg", + bg: "ui.bg_subtle", + }); + } + + return { text, styles }; + } +} + +// ============================================================================= +// Separator +// ============================================================================= + +/** + * Horizontal separator line + */ +export class Separator { + constructor( + public width: number, + public char: string = "─", + ) {} + + render(): ControlOutput { + const text = this.char.repeat(this.width); + return { + text, + styles: [{ + start: 0, + end: text.length, + fg: "ui.border", + }], + }; + } +} + +// ============================================================================= +// Label +// ============================================================================= + +/** + * Simple text label with optional styling + */ +export class Label { + constructor( + public text: string, + public fg?: string | RGB, + public bg?: string | RGB, + public bold?: boolean, + ) {} + + render(): ControlOutput { + const styles: StyleRange[] = []; + if (this.fg || this.bg || this.bold) { + styles.push({ + start: 0, + end: this.text.length, + fg: this.fg, + bg: this.bg, + bold: this.bold, + }); + } + return { text: this.text, styles }; + } +} diff --git a/crates/fresh-editor/plugins/lib/index.ts b/crates/fresh-editor/plugins/lib/index.ts index 52fd19924..a1e10eaf8 100644 --- a/crates/fresh-editor/plugins/lib/index.ts +++ b/crates/fresh-editor/plugins/lib/index.ts @@ -1,12 +1,34 @@ /** * Fresh Editor Plugin Library * - * Shared utilities for building LSP-related plugins with common patterns. + * Shared utilities for building plugins with common patterns: + * - Panel management and navigation + * - UI controls (buttons, lists, focus management) + * - Virtual buffer building with automatic styling + * - Finder/picker abstractions * * @example * ```typescript + * // Panel and navigation utilities * import { PanelManager, NavigationController, VirtualBufferFactory } from "./lib/index.ts"; * import type { Location, RGB, PanelOptions } from "./lib/index.ts"; + * + * // UI Controls for building plugin interfaces + * import { + * ButtonControl, ListControl, FocusManager, FocusState, + * VirtualBufferBuilder + * } from "./lib/index.ts"; + * + * // Build a UI with automatic style handling + * const builder = new VirtualBufferBuilder(bufferId, "my-plugin"); + * builder + * .sectionHeader("My Plugin") + * .row( + * new ButtonControl("Action", FocusState.Focused).render(), + * { text: " ", styles: [] }, + * new ButtonControl("Cancel").render() + * ) + * .build(); * ``` */ @@ -44,3 +66,25 @@ export type { FinderProvider, LivePanelOptions, } from "./finder.ts"; + +// UI Controls Library +export { + FocusState, + ButtonControl, + ToggleButton, + ListControl, + GroupedListControl, + FocusManager, + TextInputControl, + Separator, + Label, +} from "./controls.ts"; +export type { + StyleRange, + ControlOutput, + ItemRenderer, + ListGroup, +} from "./controls.ts"; + +// Virtual Buffer Builder +export { VirtualBufferBuilder, createBuilder } from "./vbuffer.ts"; diff --git a/crates/fresh-editor/plugins/lib/vbuffer.ts b/crates/fresh-editor/plugins/lib/vbuffer.ts new file mode 100644 index 000000000..390bbae97 --- /dev/null +++ b/crates/fresh-editor/plugins/lib/vbuffer.ts @@ -0,0 +1,374 @@ +/// + +/** + * Virtual Buffer Builder for Fresh Editor Plugins + * + * Eliminates manual UTF-8 byte offset calculation when building plugin UIs. + * Uses character offsets internally and handles conversion automatically. + * + * @example + * ```typescript + * import { VirtualBufferBuilder } from "./lib/vbuffer.ts"; + * import { ButtonControl, ListControl, FocusState } from "./lib/controls.ts"; + * + * const builder = new VirtualBufferBuilder(bufferId, "my-plugin"); + * + * builder + * .text(" Packages\n", [{ start: 0, end: 10, fg: "syntax.keyword" }]) + * .newline() + * .row( + * new ButtonControl("Install", FocusState.Focused).render(), + * { text: " ", styles: [] }, + * new ButtonControl("Update").render() + * ) + * .newline() + * .separator(80) + * .control(packageList.render()) + * .build(); + * ``` + */ + +import type { StyleRange, ControlOutput } from "./controls.ts"; +import type { RGB } from "./types.ts"; + +const editor = getEditor(); + +/** + * Entry being accumulated in the builder + */ +interface BuilderEntry { + text: string; + styles: StyleRange[]; +} + +/** + * Builds virtual buffer content with automatic style offset tracking. + * + * Eliminates manual utf8ByteLength() calls and offset tracking. + * Styles use character offsets - byte conversion happens automatically in build(). + */ +export class VirtualBufferBuilder { + private entries: BuilderEntry[] = []; + + constructor( + /** Buffer ID to write to */ + private bufferId: number, + /** Namespace for overlays (used in clearNamespace) */ + private namespace: string = "ui" + ) {} + + /** + * Add text with optional styles + * + * @param content - Text to add + * @param styles - Style ranges (character offsets relative to this text) + */ + text(content: string, styles?: StyleRange[]): this { + this.entries.push({ text: content, styles: styles ?? [] }); + return this; + } + + /** + * Add a newline + */ + newline(): this { + return this.text("\n"); + } + + /** + * Add multiple newlines + */ + newlines(count: number): this { + return this.text("\n".repeat(count)); + } + + /** + * Add a blank line (newline followed by newline) + */ + blankLine(): this { + return this.text("\n"); + } + + /** + * Add a horizontal separator + * + * @param width - Width in characters + * @param char - Character to use (default: "─") + * @param fg - Foreground color + */ + separator(width: number, char: string = "─", fg?: string | RGB): this { + const line = char.repeat(width); + const styles: StyleRange[] = fg + ? [{ start: 0, end: line.length, fg }] + : [{ start: 0, end: line.length, fg: "ui.border" }]; + return this.text(line + "\n", styles); + } + + /** + * Add a control's rendered output + * + * @param output - Output from a control's render() method + */ + control(output: ControlOutput): this { + this.entries.push(output); + return this; + } + + /** + * Add a row of controls/text with automatic offset adjustment + * + * @param controls - Control outputs to combine horizontally + */ + row(...controls: ControlOutput[]): this { + let combined = ""; + const allStyles: StyleRange[] = []; + let offset = 0; + + for (const ctrl of controls) { + // Shift styles by current offset + for (const style of ctrl.styles) { + allStyles.push({ + ...style, + start: style.start + offset, + end: style.end + offset, + }); + } + combined += ctrl.text; + offset += ctrl.text.length; + } + + this.entries.push({ text: combined, styles: allStyles }); + return this; + } + + /** + * Add a labeled row (label + content) + * + * @param label - Label text + * @param content - Content control output + * @param labelFg - Label foreground color + */ + labeledRow(label: string, content: ControlOutput, labelFg?: string | RGB): this { + const labelOutput: ControlOutput = { + text: label, + styles: labelFg ? [{ start: 0, end: label.length, fg: labelFg }] : [], + }; + return this.row(labelOutput, content); + } + + /** + * Add a section header + * + * @param title - Section title + * @param fg - Foreground color (default: syntax.keyword) + */ + sectionHeader(title: string, fg: string | RGB = "syntax.keyword"): this { + return this.text(title + "\n", [{ + start: 0, + end: title.length, + fg, + bold: true, + }]); + } + + /** + * Add styled text with a single style applied to the entire text + * + * @param content - Text content + * @param fg - Foreground color + * @param bg - Background color + * @param bold - Bold text + */ + styled(content: string, fg?: string | RGB, bg?: string | RGB, bold?: boolean): this { + const styles: StyleRange[] = []; + if (fg || bg || bold) { + styles.push({ start: 0, end: content.length, fg, bg, bold }); + } + return this.text(content, styles); + } + + /** + * Add padded text (content padded to width) + * + * @param content - Text to pad + * @param width - Target width + * @param styles - Optional styles + */ + padded(content: string, width: number, styles?: StyleRange[]): this { + const padded = content.length >= width + ? content.slice(0, width) + : content + " ".repeat(width - content.length); + return this.text(padded, styles); + } + + /** + * Add a two-column row with fixed widths + * + * @param left - Left column content + * @param right - Right column content + * @param leftWidth - Width of left column + * @param divider - Divider between columns (default: " | ") + */ + twoColumn( + left: ControlOutput, + right: ControlOutput, + leftWidth: number, + divider: string = " | " + ): this { + // Pad/truncate left column + let leftText = left.text; + if (leftText.length > leftWidth) { + leftText = leftText.slice(0, leftWidth - 1) + "..."; + } else { + leftText = leftText.padEnd(leftWidth); + } + + const paddedLeft: ControlOutput = { + text: leftText, + styles: left.styles.map(s => ({ + ...s, + end: Math.min(s.end, leftText.length), + })), + }; + + const dividerOutput: ControlOutput = { + text: divider, + styles: [{ start: 0, end: divider.length, fg: "ui.border" }], + }; + + return this.row(paddedLeft, dividerOutput, right); + } + + /** + * Conditionally add content + * + * @param condition - Whether to add the content + * @param fn - Function that adds content to the builder + */ + when(condition: boolean, fn: (builder: this) => void): this { + if (condition) { + fn(this); + } + return this; + } + + /** + * Add content for each item in an array + * + * @param items - Items to iterate + * @param fn - Function to add content for each item + */ + forEach(items: T[], fn: (builder: this, item: T, index: number) => void): this { + items.forEach((item, index) => fn(this, item, index)); + return this; + } + + /** + * Clear the builder to start fresh + */ + clear(): this { + this.entries = []; + return this; + } + + /** + * Build and apply to the virtual buffer + * + * This method: + * 1. Combines all text entries + * 2. Converts character offsets to byte offsets + * 3. Sets the buffer content + * 4. Clears old overlays + * 5. Applies new overlays + */ + build(): void { + // Combine all text and adjust style offsets + let fullText = ""; + const allStyles: StyleRange[] = []; + let charOffset = 0; + + for (const entry of this.entries) { + for (const style of entry.styles) { + allStyles.push({ + ...style, + start: style.start + charOffset, + end: style.end + charOffset, + }); + } + fullText += entry.text; + charOffset += entry.text.length; + } + + // Convert to TextPropertyEntry format + const textEntries: TextPropertyEntry[] = [{ text: fullText, properties: {} }]; + editor.setVirtualBufferContent(this.bufferId, textEntries); + + // Clear existing overlays and apply new ones + editor.clearNamespace(this.bufferId, this.namespace); + + for (const style of allStyles) { + // Convert character offsets to byte offsets + const byteStart = this.charToByteOffset(fullText, style.start); + const byteEnd = this.charToByteOffset(fullText, style.end); + + // Build overlay options + const options: Record = {}; + if (style.fg !== undefined) options.fg = style.fg; + if (style.bg !== undefined) options.bg = style.bg; + if (style.bold) options.bold = true; + if (style.underline) options.underline = true; + + if (Object.keys(options).length > 0) { + editor.addOverlay(this.bufferId, this.namespace, byteStart, byteEnd, options); + } + } + } + + /** + * Get the combined text without building (useful for debugging) + */ + getText(): string { + return this.entries.map(e => e.text).join(""); + } + + /** + * Get the combined styles without building (useful for debugging) + */ + getStyles(): StyleRange[] { + const allStyles: StyleRange[] = []; + let charOffset = 0; + + for (const entry of this.entries) { + for (const style of entry.styles) { + allStyles.push({ + ...style, + start: style.start + charOffset, + end: style.end + charOffset, + }); + } + charOffset += entry.text.length; + } + + return allStyles; + } + + /** + * Convert character offset to byte offset for UTF-8 text + */ + private charToByteOffset(text: string, charOffset: number): number { + // Use TextEncoder for accurate UTF-8 byte counting + const encoder = new TextEncoder(); + const prefix = text.slice(0, charOffset); + return encoder.encode(prefix).length; + } +} + +/** + * Create a new VirtualBufferBuilder + * + * @param bufferId - Buffer ID to write to + * @param namespace - Namespace for overlays + */ +export function createBuilder(bufferId: number, namespace: string = "ui"): VirtualBufferBuilder { + return new VirtualBufferBuilder(bufferId, namespace); +} diff --git a/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md b/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md index 82c202259..aa1bedcc6 100644 --- a/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md +++ b/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md @@ -1164,14 +1164,14 @@ The **Layout DSL** (Part 5) is a future direction that adds compositional UI bui ## Files to Create -| File | Purpose | Lines (est.) | -|------|---------|--------------| -| `src/view/ui/layout.rs` | `point_in_rect()` helper | ~30 | -| `src/view/ui/focus.rs` | `FocusManager` | ~60 | -| `plugins/lib/controls.ts` | `ButtonControl`, `ListControl`, `FocusManager` | ~200 | -| `plugins/lib/vbuffer.ts` | `VirtualBufferBuilder` | ~100 | +| File | Purpose | Lines (est.) | Status | +|------|---------|--------------|--------| +| `src/view/ui/layout.rs` | `point_in_rect()` helper | ~30 | ✅ Done | +| `src/view/ui/focus.rs` | `FocusManager` | ~60 | ✅ Done | +| `plugins/lib/controls.ts` | `ButtonControl`, `ListControl`, `FocusManager`, etc. | ~550 | ✅ Done | +| `plugins/lib/vbuffer.ts` | `VirtualBufferBuilder` | ~300 | ✅ Done | -**Total new code: ~390 lines** (mostly TypeScript for plugins) +**Total new code: ~940 lines** (expanded TypeScript library with more controls) ## Files to Modify @@ -1215,6 +1215,10 @@ The key principle: **extract existing code into shared modules first**, then hav | 5 | Migrate `settings/state.rs` to use `FocusManager` | ✅ Done | | 6 | Add `MenuLayout` + `MenuHit` to `menu.rs` | ✅ Done | | 7 | Add `TabLayout` + `TabHit` to `tabs.rs` | ✅ Done | +| 8 | Create `plugins/lib/controls.ts` (ButtonControl, ListControl, FocusManager) | ✅ Done | +| 9 | Create `plugins/lib/vbuffer.ts` (VirtualBufferBuilder) | ✅ Done | +| 10 | Update `plugins/lib/index.ts` exports | ✅ Done | +| 11 | Migrate `pkg.ts` to use controls library | Pending | --- From 9aa3717156e8aae1ad82ccc6959217043cb3ac16 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 10:21:35 +0000 Subject: [PATCH 2/7] refactor(pkg): Migrate to VirtualBufferBuilder controls library Refactor the package manager UI to use the new controls library: - Use VirtualBufferBuilder for automatic UTF-8 byte offset handling - Use ButtonControl for buttons (filter, sync, action buttons) - Remove manual utf8ByteLength() function - Remove buildListViewEntries() and applyPkgManagerHighlighting() - Add new renderPkgManagerUI() function using VirtualBufferBuilder - Extract buildLeftPanel() and buildRightPanel() helper functions This reduces the UI code by ~100 lines while improving maintainability. The VirtualBufferBuilder handles character-to-byte conversion automatically, eliminating a common source of bugs in UTF-8 offset calculations. https://claude.ai/code/session_01Kt49PHNWUZjs4TNk8zxZZ1 --- crates/fresh-editor/plugins/pkg.ts | 564 ++++++++++++----------------- 1 file changed, 230 insertions(+), 334 deletions(-) diff --git a/crates/fresh-editor/plugins/pkg.ts b/crates/fresh-editor/plugins/pkg.ts index 4f3e99eb2..4a5cd5adc 100644 --- a/crates/fresh-editor/plugins/pkg.ts +++ b/crates/fresh-editor/plugins/pkg.ts @@ -13,22 +13,18 @@ * - Version pinning with tags, branches, or commits * - Lockfile for reproducibility * - * TODO: Plugin UI Component Library - * --------------------------------- - * The UI code in this plugin manually constructs buttons, lists, split views, - * and focus management using raw text property entries. This is verbose and - * error-prone. We need a shared UI component library that plugins can use to - * build interfaces in virtual buffers: - * - * - Buttons, lists, scroll bars, tabs, split views, text inputs, etc. - * - Automatic keyboard navigation and focus management - * - Theme-aware styling - * - * The editor's settings UI already implements similar components - these could - * be unified into a shared framework. See PLUGIN_MARKETPLACE_DESIGN.md for details. + * UI Implementation: + * Uses the shared controls library (lib/controls.ts, lib/vbuffer.ts) for + * building the package manager interface with automatic styling and + * UTF-8 byte offset handling. */ import { Finder } from "./lib/finder.ts"; +import { + ButtonControl, + FocusState, + VirtualBufferBuilder, +} from "./lib/index.ts"; const editor = getEditor(); @@ -1478,10 +1474,15 @@ function wrapText(text: string, maxWidth: number): string[] { } /** - * Build virtual buffer entries for the package manager (split-view layout) + * Render the package manager UI using VirtualBufferBuilder + * + * This replaces the old buildListViewEntries() and applyPkgManagerHighlighting() + * functions with a cleaner implementation using the controls library. */ -function buildListViewEntries(): TextPropertyEntry[] { - const entries: TextPropertyEntry[] = []; +function renderPkgManagerUI(): void { + if (pkgState.bufferId === null) return; + + const builder = new VirtualBufferBuilder(pkgState.bufferId, "pkg"); const items = getFilteredItems(); const selectedItem = items.length > 0 && pkgState.selectedIndex < items.length ? items[pkgState.selectedIndex] : null; @@ -1489,15 +1490,10 @@ function buildListViewEntries(): TextPropertyEntry[] { const availableItems = items.filter(i => !i.installed); // === HEADER === - entries.push({ - text: " Packages\n", - properties: { type: "header" }, - }); - - // Empty line after header - entries.push({ text: "\n", properties: { type: "blank" } }); + builder.styled(" Packages\n", pkgTheme.header.fg?.theme ?? pkgTheme.header.fg?.rgb); + builder.newline(); - // === SEARCH BAR (input-style) === + // === SEARCH BAR === const searchFocused = isButtonFocused("search"); const searchInputWidth = 30; const searchText = pkgState.searchQuery || ""; @@ -1505,15 +1501,17 @@ function buildListViewEntries(): TextPropertyEntry[] { ? searchText.slice(0, searchInputWidth - 2) + "…" : searchText.padEnd(searchInputWidth); - entries.push({ text: " Search: ", properties: { type: "search-label" } }); - entries.push({ - text: searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `, - properties: { type: "search-input", focused: searchFocused }, - }); - entries.push({ text: "\n", properties: { type: "newline" } }); - - // === FILTER BAR with focusable buttons === - const filters: Array<{ id: string; label: string }> = [ + builder.styled(" Search: ", pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb); + const searchStyle = searchFocused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox; + builder.styled( + searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `, + searchStyle.fg?.theme ?? searchStyle.fg?.rgb, + searchStyle.bg?.theme ?? searchStyle.bg?.rgb + ); + builder.newline(); + + // === FILTER BAR === + const filters = [ { id: "all", label: "All" }, { id: "installed", label: "Installed" }, { id: "plugins", label: "Plugins" }, @@ -1521,397 +1519,298 @@ function buildListViewEntries(): TextPropertyEntry[] { { id: "languages", label: "Languages" }, ]; - // Build filter buttons with position tracking - let filterBarParts: Array<{ text: string; type: string; focused?: boolean; active?: boolean }> = []; - filterBarParts.push({ text: " ", type: "spacer" }); - + builder.text(" "); for (let i = 0; i < filters.length; i++) { const f = filters[i]; const isActive = pkgState.filter === f.id; const isFocused = isButtonFocused("filter", i); - // Always reserve space for brackets - show [ ] when focused, spaces when not - const leftBracket = isFocused ? "[" : " "; - const rightBracket = isFocused ? "]" : " "; - filterBarParts.push({ - text: `${leftBracket} ${f.label} ${rightBracket}`, - type: "filter-btn", - focused: isFocused, - active: isActive, - }); + + let btnFg: string | [number, number, number] | undefined; + let btnBg: string | [number, number, number] | undefined; + + if (isFocused && isActive) { + btnFg = pkgTheme.buttonFocused.fg?.theme ?? pkgTheme.buttonFocused.fg?.rgb; + btnBg = pkgTheme.buttonFocused.bg?.theme ?? pkgTheme.buttonFocused.bg?.rgb; + } else if (isFocused) { + btnFg = pkgTheme.filterFocused.fg?.theme ?? pkgTheme.filterFocused.fg?.rgb; + btnBg = pkgTheme.filterFocused.bg?.theme ?? pkgTheme.filterFocused.bg?.rgb; + } else if (isActive) { + btnFg = pkgTheme.filterActive.fg?.theme ?? pkgTheme.filterActive.fg?.rgb; + btnBg = pkgTheme.filterActive.bg?.theme ?? pkgTheme.filterActive.bg?.rgb; + } else { + btnFg = pkgTheme.filterInactive.fg?.theme ?? pkgTheme.filterInactive.fg?.rgb; + } + + const btn = new ButtonControl(f.label, isFocused ? FocusState.Focused : FocusState.Normal); + const btnOutput = btn.render(); + // Override styles with our theme colors + builder.styled(btnOutput.text, btnFg, btnBg); } - filterBarParts.push({ text: " ", type: "spacer" }); + builder.text(" "); - // Sync button - always reserve space for brackets + // Sync button const syncFocused = isButtonFocused("sync"); - const syncLeft = syncFocused ? "[" : " "; - const syncRight = syncFocused ? "]" : " "; - filterBarParts.push({ text: `${syncLeft} Sync ${syncRight}`, type: "sync-btn", focused: syncFocused }); - - // Emit each filter bar part as separate entry for individual styling - for (const part of filterBarParts) { - entries.push({ - text: part.text, - properties: { - type: part.type, - focused: part.focused, - active: part.active, - }, - }); - } - entries.push({ text: "\n", properties: { type: "newline" } }); + const syncBtn = new ButtonControl("Sync", syncFocused ? FocusState.Focused : FocusState.Normal); + const syncOutput = syncBtn.render(); + const syncStyle = syncFocused ? pkgTheme.buttonFocused : pkgTheme.button; + builder.styled( + syncOutput.text, + syncStyle.fg?.theme ?? syncStyle.fg?.rgb, + syncStyle.bg?.theme ?? syncStyle.bg?.rgb + ); + builder.newline(); // === TOP SEPARATOR === - entries.push({ - text: " " + "─".repeat(TOTAL_WIDTH - 2) + "\n", - properties: { type: "separator" }, - }); + builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", pkgTheme.separator.fg?.rgb); // === SPLIT VIEW: Package list on left, Details on right === + const leftLines = buildLeftPanel(installedItems, availableItems, items); + const rightLines = buildRightPanel(selectedItem); - // Build left panel lines (package list) - const leftLines: Array<{ text: string; type: string; selected?: boolean; installed?: boolean }> = []; + // Merge left and right panels into rows + const maxRows = Math.max(leftLines.length, rightLines.length, 8); + for (let i = 0; i < maxRows; i++) { + const leftItem = leftLines[i]; + const rightItem = rightLines[i]; + + // Left side (padded to fixed width) + const leftText = leftItem ? (" " + leftItem.text).padEnd(LIST_WIDTH) : " ".repeat(LIST_WIDTH); + if (leftItem) { + builder.styled(leftText, leftItem.fg, leftItem.bg); + } else { + builder.text(leftText); + } + + // Divider + builder.styled("│", pkgTheme.divider.fg?.rgb); + + // Right side + const rightText = rightItem ? " " + rightItem.text : ""; + if (rightItem) { + builder.styled(rightText, rightItem.fg, rightItem.bg); + } else { + builder.text(rightText); + } + + builder.newline(); + } + + // === BOTTOM SEPARATOR === + builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", pkgTheme.separator.fg?.rgb); + + // === HELP LINE === + let helpText = " ↑↓ Navigate Tab Next / Search Enter "; + if (pkgState.focus.type === "action") { + helpText += "Activate"; + } else if (pkgState.focus.type === "filter") { + helpText += "Filter"; + } else if (pkgState.focus.type === "sync") { + helpText += "Sync"; + } else if (pkgState.focus.type === "search") { + helpText += "Search"; + } else { + helpText += "Select"; + } + helpText += " Esc Close\n"; + builder.styled(helpText, pkgTheme.help.fg?.theme ?? pkgTheme.help.fg?.rgb); + + // Build and apply to buffer + builder.build(); +} + +/** Line item for the split-view panels */ +interface PanelLine { + text: string; + fg?: string | [number, number, number]; + bg?: string | [number, number, number]; +} + +/** Build the left panel (package list) */ +function buildLeftPanel( + installedItems: PackageListItem[], + availableItems: PackageListItem[], + allItems: PackageListItem[] +): PanelLine[] { + const lines: PanelLine[] = []; + const listFocused = pkgState.focus.type === "list"; // Installed section if (installedItems.length > 0) { - leftLines.push({ text: `INSTALLED (${installedItems.length})`, type: "section-title" }); + lines.push({ + text: `INSTALLED (${installedItems.length})`, + fg: pkgTheme.sectionTitle.fg?.theme ?? pkgTheme.sectionTitle.fg?.rgb, + }); let idx = 0; for (const item of installedItems) { const isSelected = idx === pkgState.selectedIndex; - const listFocused = pkgState.focus.type === "list"; const prefix = isSelected && listFocused ? "▸" : " "; const status = item.updateAvailable ? "↑" : "✓"; const ver = item.version.length > 7 ? item.version.slice(0, 6) + "…" : item.version; const name = item.name.length > 18 ? item.name.slice(0, 17) + "…" : item.name; const line = `${prefix} ${name.padEnd(18)} ${ver.padEnd(7)} ${status}`; - leftLines.push({ text: line, type: "package-row", selected: isSelected, installed: true }); + + if (isSelected) { + lines.push({ + text: line, + fg: pkgTheme.selected.fg?.theme ?? pkgTheme.selected.fg?.rgb, + bg: pkgTheme.selected.bg?.theme ?? pkgTheme.selected.bg?.rgb, + }); + } else { + lines.push({ + text: line, + fg: pkgTheme.installed.fg?.theme ?? pkgTheme.installed.fg?.rgb, + }); + } idx++; } } // Available section if (availableItems.length > 0) { - if (leftLines.length > 0) leftLines.push({ text: "", type: "blank" }); - leftLines.push({ text: `AVAILABLE (${availableItems.length})`, type: "section-title" }); + if (lines.length > 0) lines.push({ text: "" }); + lines.push({ + text: `AVAILABLE (${availableItems.length})`, + fg: pkgTheme.sectionTitle.fg?.theme ?? pkgTheme.sectionTitle.fg?.rgb, + }); let idx = installedItems.length; for (const item of availableItems) { const isSelected = idx === pkgState.selectedIndex; - const listFocused = pkgState.focus.type === "list"; const prefix = isSelected && listFocused ? "▸" : " "; const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : "P"; const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name; const line = `${prefix} ${name.padEnd(22)} [${typeTag}]`; - leftLines.push({ text: line, type: "package-row", selected: isSelected, installed: false }); + + if (isSelected) { + lines.push({ + text: line, + fg: pkgTheme.selected.fg?.theme ?? pkgTheme.selected.fg?.rgb, + bg: pkgTheme.selected.bg?.theme ?? pkgTheme.selected.bg?.rgb, + }); + } else { + lines.push({ + text: line, + fg: pkgTheme.available.fg?.theme ?? pkgTheme.available.fg?.rgb, + }); + } idx++; } } - // Empty state for left panel - if (items.length === 0) { + // Empty state + if (allItems.length === 0) { if (pkgState.isLoading) { - leftLines.push({ text: "Loading...", type: "empty-state" }); + lines.push({ text: "Loading...", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); } else if (!isRegistrySynced()) { - leftLines.push({ text: "Registry not synced", type: "empty-state" }); - leftLines.push({ text: "Tab to Sync button", type: "empty-state" }); + lines.push({ text: "Registry not synced", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); + lines.push({ text: "Tab to Sync button", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); } else { - leftLines.push({ text: "No packages found", type: "empty-state" }); + lines.push({ text: "No packages found", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); } } - // Build right panel lines (details for selected package) - const rightLines: Array<{ text: string; type: string; focused?: boolean; btnIndex?: number }> = []; + return lines; +} + +/** Build the right panel (package details) */ +function buildRightPanel(selectedItem: PackageListItem | null): PanelLine[] { + const lines: PanelLine[] = []; if (selectedItem) { // Package name - rightLines.push({ text: selectedItem.name, type: "detail-title" }); - rightLines.push({ text: "─".repeat(Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2)), type: "detail-sep" }); + lines.push({ + text: selectedItem.name, + fg: pkgTheme.header.fg?.theme ?? pkgTheme.header.fg?.rgb, + }); + lines.push({ + text: "─".repeat(Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2)), + fg: pkgTheme.separator.fg?.rgb, + }); - // Version / Author / License on one line + // Version / Author / License let metaLine = `v${selectedItem.version}`; if (selectedItem.author) metaLine += ` • ${selectedItem.author}`; if (selectedItem.license) metaLine += ` • ${selectedItem.license}`; if (metaLine.length > DETAIL_WIDTH - 2) metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "..."; - rightLines.push({ text: metaLine, type: "detail-meta" }); + lines.push({ + text: metaLine, + fg: pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb, + }); - rightLines.push({ text: "", type: "blank" }); + lines.push({ text: "" }); // Description (wrapped) const descText = selectedItem.description || "No description available"; const descLines = wrapText(descText, DETAIL_WIDTH - 2); for (const line of descLines) { - rightLines.push({ text: line, type: "detail-desc" }); + lines.push({ + text: line, + fg: pkgTheme.description.fg?.theme ?? pkgTheme.description.fg?.rgb, + }); } - rightLines.push({ text: "", type: "blank" }); + lines.push({ text: "" }); // Keywords if (selectedItem.keywords && selectedItem.keywords.length > 0) { const kwText = selectedItem.keywords.slice(0, 4).join(", "); - rightLines.push({ text: `Tags: ${kwText}`, type: "detail-tags" }); - rightLines.push({ text: "", type: "blank" }); + lines.push({ + text: `Tags: ${kwText}`, + fg: pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb, + }); + lines.push({ text: "" }); } // Repository URL if (selectedItem.repository) { - // Shorten URL for display (remove protocol, truncate if needed) let displayUrl = selectedItem.repository .replace(/^https?:\/\//, "") .replace(/\.git$/, ""); if (displayUrl.length > DETAIL_WIDTH - 2) { displayUrl = displayUrl.slice(0, DETAIL_WIDTH - 5) + "..."; } - rightLines.push({ text: displayUrl, type: "detail-url" }); - rightLines.push({ text: "", type: "blank" }); + lines.push({ + text: displayUrl, + fg: pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb, + }); + lines.push({ text: "" }); } - // Action buttons - always reserve space for brackets + // Action buttons const actions = getActionButtons(); for (let i = 0; i < actions.length; i++) { const focused = isButtonFocused("action", i); - const leftBracket = focused ? "[" : " "; - const rightBracket = focused ? "]" : " "; - const btnText = `${leftBracket} ${actions[i]} ${rightBracket}`; - rightLines.push({ text: btnText, type: "action-btn", focused, btnIndex: i }); + const btn = new ButtonControl(actions[i], focused ? FocusState.Focused : FocusState.Normal); + const btnOutput = btn.render(); + const btnStyle = focused ? pkgTheme.buttonFocused : pkgTheme.button; + lines.push({ + text: btnOutput.text, + fg: btnStyle.fg?.theme ?? btnStyle.fg?.rgb, + bg: btnStyle.bg?.theme ?? btnStyle.bg?.rgb, + }); } } else { - rightLines.push({ text: "Select a package", type: "empty-state" }); - rightLines.push({ text: "to view details", type: "empty-state" }); - } - - // Merge left and right panels into rows - const maxRows = Math.max(leftLines.length, rightLines.length, 8); - for (let i = 0; i < maxRows; i++) { - const leftItem = leftLines[i]; - const rightItem = rightLines[i]; - - // Left side (padded to fixed width) - const leftText = leftItem ? (" " + leftItem.text) : ""; - entries.push({ - text: leftText.padEnd(LIST_WIDTH), - properties: { - type: leftItem?.type || "blank", - selected: leftItem?.selected, - installed: leftItem?.installed, - }, + lines.push({ + text: "Select a package", + fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb, }); - - // Divider - entries.push({ text: "│", properties: { type: "divider" } }); - - // Right side - const rightText = rightItem ? (" " + rightItem.text) : ""; - entries.push({ - text: rightText, - properties: { - type: rightItem?.type || "blank", - focused: rightItem?.focused, - btnIndex: rightItem?.btnIndex, - }, + lines.push({ + text: "to view details", + fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb, }); - - entries.push({ text: "\n", properties: { type: "newline" } }); } - // === BOTTOM SEPARATOR === - entries.push({ - text: " " + "─".repeat(TOTAL_WIDTH - 2) + "\n", - properties: { type: "separator" }, - }); - - // === HELP LINE === - let helpText = " ↑↓ Navigate Tab Next / Search Enter "; - if (pkgState.focus.type === "action") { - helpText += "Activate"; - } else if (pkgState.focus.type === "filter") { - helpText += "Filter"; - } else if (pkgState.focus.type === "sync") { - helpText += "Sync"; - } else if (pkgState.focus.type === "search") { - helpText += "Search"; - } else { - helpText += "Select"; - } - helpText += " Esc Close\n"; - - entries.push({ - text: helpText, - properties: { type: "help" }, - }); - - return entries; + return lines; } -/** - * Calculate UTF-8 byte length of a string. - * Needed because string.length returns character count, not byte count. - * Unicode chars like ▸ and ─ are 1 char but 3 bytes in UTF-8. - */ -function utf8ByteLength(str: string): number { - let bytes = 0; - for (let i = 0; i < str.length; i++) { - const code = str.charCodeAt(i); - if (code < 0x80) { - bytes += 1; - } else if (code < 0x800) { - bytes += 2; - } else if (code >= 0xD800 && code <= 0xDBFF) { - // Surrogate pair = 4 bytes, skip low surrogate - bytes += 4; - i++; - } else { - bytes += 3; - } - } - return bytes; -} - -/** - * Apply theme-aware highlighting to the package manager view - */ -function applyPkgManagerHighlighting(): void { - if (pkgState.bufferId === null) return; - - // Clear existing overlays - editor.clearNamespace(pkgState.bufferId, "pkg"); - - const entries = buildListViewEntries(); - let byteOffset = 0; - - for (const entry of entries) { - const props = entry.properties as Record; - const len = utf8ByteLength(entry.text); - - // Determine theme colors based on entry type - let themeStyle: ThemeColor | null = null; - - switch (props.type) { - case "header": - themeStyle = pkgTheme.header; - break; - - case "section-title": - themeStyle = pkgTheme.sectionTitle; - break; - - case "filter-btn": - if (props.focused && props.active) { - // Both focused and active - use focused style - themeStyle = pkgTheme.buttonFocused; - } else if (props.focused) { - // Only focused (not the active filter) - themeStyle = pkgTheme.filterFocused; - } else if (props.active) { - // Active filter but not focused - themeStyle = pkgTheme.filterActive; - } else { - themeStyle = pkgTheme.filterInactive; - } - break; - - case "sync-btn": - themeStyle = props.focused ? pkgTheme.buttonFocused : pkgTheme.button; - break; - - case "search-label": - themeStyle = pkgTheme.infoLabel; - break; - - case "search-input": - // Search input field styling - distinct background - themeStyle = props.focused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox; - break; - - case "package-row": - if (props.selected) { - themeStyle = pkgTheme.selected; - } else if (props.installed) { - themeStyle = pkgTheme.installed; - } else { - themeStyle = pkgTheme.available; - } - break; - - case "detail-title": - themeStyle = pkgTheme.header; - break; - - case "detail-sep": - case "separator": - themeStyle = pkgTheme.separator; - break; - - case "divider": - themeStyle = pkgTheme.divider; - break; - - case "detail-meta": - case "detail-tags": - case "detail-url": - themeStyle = pkgTheme.infoLabel; - break; - - case "detail-desc": - themeStyle = pkgTheme.description; - break; - - case "action-btn": - themeStyle = props.focused ? pkgTheme.buttonFocused : pkgTheme.button; - break; - - case "help": - themeStyle = pkgTheme.help; - break; - - case "empty-state": - themeStyle = pkgTheme.emptyState; - break; - } - - if (themeStyle) { - const fg = themeStyle.fg; - const bg = themeStyle.bg; - - // Build overlay options - prefer theme keys, fallback to RGB - const options: Record = {}; - - if (fg?.theme) { - options.fg = fg.theme; - } else if (fg?.rgb) { - options.fg = fg.rgb; - } - - if (bg?.theme) { - options.bg = bg.theme; - } else if (bg?.rgb) { - options.bg = bg.rgb; - } - - if (Object.keys(options).length > 0) { - editor.addOverlay( - pkgState.bufferId, - "pkg", - byteOffset, - byteOffset + len, - options - ); - } - } - - byteOffset += len; - } -} /** * Update the package manager view */ function updatePkgManagerView(): void { - if (pkgState.bufferId === null) return; - - const entries = buildListViewEntries(); - editor.setVirtualBufferContent(pkgState.bufferId, entries); - applyPkgManagerHighlighting(); + renderPkgManagerUI(); } /** @@ -1941,17 +1840,14 @@ async function openPackageManager(): Promise { pkgState.items = buildPackageList(); pkgState.isLoading = false; - // Build initial entries - const entries = buildListViewEntries(); - - // Create virtual buffer + // Create virtual buffer with placeholder content (will be rendered immediately after) const result = await editor.createVirtualBufferInExistingSplit({ name: "*Packages*", mode: "pkg-manager", readOnly: true, editingDisabled: true, showCursors: false, - entries: entries, + entries: [{ text: "", properties: {} }], splitId: pkgState.splitId!, showLineNumbers: false, }); @@ -1959,8 +1855,8 @@ async function openPackageManager(): Promise { pkgState.bufferId = result.bufferId; pkgState.isOpen = true; - // Apply initial highlighting - applyPkgManagerHighlighting(); + // Render initial UI + renderPkgManagerUI(); // Sync registry in background and update view when done // User can still interact with installed packages during sync From 972d58f7f0a4033dbd6015939c7ef2a30287b169 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 10:22:12 +0000 Subject: [PATCH 3/7] docs: Mark pkg.ts migration as complete in UI framework plan All 11 implementation steps are now complete: 1-7: Rust layout utilities (point_in_rect, FocusManager, MenuLayout, TabLayout) 8-10: TypeScript controls library (controls.ts, vbuffer.ts, exports) 11: pkg.ts migration to use the controls library https://claude.ai/code/session_01Kt49PHNWUZjs4TNk8zxZZ1 --- docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md b/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md index aa1bedcc6..b6c0b8210 100644 --- a/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md +++ b/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md @@ -1218,7 +1218,7 @@ The key principle: **extract existing code into shared modules first**, then hav | 8 | Create `plugins/lib/controls.ts` (ButtonControl, ListControl, FocusManager) | ✅ Done | | 9 | Create `plugins/lib/vbuffer.ts` (VirtualBufferBuilder) | ✅ Done | | 10 | Update `plugins/lib/index.ts` exports | ✅ Done | -| 11 | Migrate `pkg.ts` to use controls library | Pending | +| 11 | Migrate `pkg.ts` to use controls library | ✅ Done | --- From 30a53604f393699f10d894c7714cef4f8d904d6a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 12:06:38 +0000 Subject: [PATCH 4/7] refactor(pkg): Add theme helpers to reduce verbose theme access Add themeColor(), themeFg(), and themeBg() helpers to simplify the verbose pattern of `pkgTheme.xxx.fg?.theme ?? pkgTheme.xxx.fg?.rgb`. This reduces the UI rendering code by ~74 lines while maintaining the same visual behavior. https://claude.ai/code/session_01Kt49PHNWUZjs4TNk8zxZZ1 --- crates/fresh-editor/plugins/pkg.ts | 210 ++++++++++------------------- 1 file changed, 68 insertions(+), 142 deletions(-) diff --git a/crates/fresh-editor/plugins/pkg.ts b/crates/fresh-editor/plugins/pkg.ts index 4a5cd5adc..6c9f9227d 100644 --- a/crates/fresh-editor/plugins/pkg.ts +++ b/crates/fresh-editor/plugins/pkg.ts @@ -1232,6 +1232,24 @@ const pkgTheme: Record = { statusUpdate: { fg: { rgb: [220, 180, 80] } }, }; +/** Extract theme colors with fallback to RGB - simplifies theme access */ +function themeColor(style: ThemeColor): { fg?: string | [number, number, number]; bg?: string | [number, number, number] } { + return { + fg: style.fg?.theme ?? style.fg?.rgb, + bg: style.bg?.theme ?? style.bg?.rgb, + }; +} + +/** Get fg color from theme style */ +function themeFg(style: ThemeColor): string | [number, number, number] | undefined { + return style.fg?.theme ?? style.fg?.rgb; +} + +/** Get bg color from theme style */ +function themeBg(style: ThemeColor): string | [number, number, number] | undefined { + return style.bg?.theme ?? style.bg?.rgb; +} + // Define pkg-manager mode with arrow key navigation editor.defineMode( "pkg-manager", @@ -1490,79 +1508,45 @@ function renderPkgManagerUI(): void { const availableItems = items.filter(i => !i.installed); // === HEADER === - builder.styled(" Packages\n", pkgTheme.header.fg?.theme ?? pkgTheme.header.fg?.rgb); + builder.styled(" Packages\n", themeFg(pkgTheme.header)); builder.newline(); // === SEARCH BAR === const searchFocused = isButtonFocused("search"); - const searchInputWidth = 30; const searchText = pkgState.searchQuery || ""; - const searchDisplay = searchText.length > searchInputWidth - 1 - ? searchText.slice(0, searchInputWidth - 2) + "…" - : searchText.padEnd(searchInputWidth); - - builder.styled(" Search: ", pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb); - const searchStyle = searchFocused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox; - builder.styled( - searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `, - searchStyle.fg?.theme ?? searchStyle.fg?.rgb, - searchStyle.bg?.theme ?? searchStyle.bg?.rgb - ); + const searchDisplay = searchText.length > 29 ? searchText.slice(0, 27) + "…" : searchText.padEnd(30); + const searchStyle = themeColor(searchFocused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox); + + builder.styled(" Search: ", themeFg(pkgTheme.infoLabel)); + builder.styled(searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `, searchStyle.fg, searchStyle.bg); builder.newline(); // === FILTER BAR === - const filters = [ - { id: "all", label: "All" }, - { id: "installed", label: "Installed" }, - { id: "plugins", label: "Plugins" }, - { id: "themes", label: "Themes" }, - { id: "languages", label: "Languages" }, - ]; + const filters = ["All", "Installed", "Plugins", "Themes", "Languages"]; + const filterIds = ["all", "installed", "plugins", "themes", "languages"]; builder.text(" "); for (let i = 0; i < filters.length; i++) { - const f = filters[i]; - const isActive = pkgState.filter === f.id; + const isActive = pkgState.filter === filterIds[i]; const isFocused = isButtonFocused("filter", i); - - let btnFg: string | [number, number, number] | undefined; - let btnBg: string | [number, number, number] | undefined; - - if (isFocused && isActive) { - btnFg = pkgTheme.buttonFocused.fg?.theme ?? pkgTheme.buttonFocused.fg?.rgb; - btnBg = pkgTheme.buttonFocused.bg?.theme ?? pkgTheme.buttonFocused.bg?.rgb; - } else if (isFocused) { - btnFg = pkgTheme.filterFocused.fg?.theme ?? pkgTheme.filterFocused.fg?.rgb; - btnBg = pkgTheme.filterFocused.bg?.theme ?? pkgTheme.filterFocused.bg?.rgb; - } else if (isActive) { - btnFg = pkgTheme.filterActive.fg?.theme ?? pkgTheme.filterActive.fg?.rgb; - btnBg = pkgTheme.filterActive.bg?.theme ?? pkgTheme.filterActive.bg?.rgb; - } else { - btnFg = pkgTheme.filterInactive.fg?.theme ?? pkgTheme.filterInactive.fg?.rgb; - } - - const btn = new ButtonControl(f.label, isFocused ? FocusState.Focused : FocusState.Normal); - const btnOutput = btn.render(); - // Override styles with our theme colors - builder.styled(btnOutput.text, btnFg, btnBg); + const style = themeColor( + isFocused ? (isActive ? pkgTheme.buttonFocused : pkgTheme.filterFocused) + : (isActive ? pkgTheme.filterActive : pkgTheme.filterInactive) + ); + const btn = new ButtonControl(filters[i], isFocused ? FocusState.Focused : FocusState.Normal); + builder.styled(btn.render().text, style.fg, style.bg); } builder.text(" "); // Sync button const syncFocused = isButtonFocused("sync"); - const syncBtn = new ButtonControl("Sync", syncFocused ? FocusState.Focused : FocusState.Normal); - const syncOutput = syncBtn.render(); - const syncStyle = syncFocused ? pkgTheme.buttonFocused : pkgTheme.button; - builder.styled( - syncOutput.text, - syncStyle.fg?.theme ?? syncStyle.fg?.rgb, - syncStyle.bg?.theme ?? syncStyle.bg?.rgb - ); + const syncStyle = themeColor(syncFocused ? pkgTheme.buttonFocused : pkgTheme.button); + builder.styled(new ButtonControl("Sync", syncFocused ? FocusState.Focused : FocusState.Normal).render().text, syncStyle.fg, syncStyle.bg); builder.newline(); // === TOP SEPARATOR === - builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", pkgTheme.separator.fg?.rgb); + builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", themeFg(pkgTheme.separator)); // === SPLIT VIEW: Package list on left, Details on right === const leftLines = buildLeftPanel(installedItems, availableItems, items); @@ -1583,7 +1567,7 @@ function renderPkgManagerUI(): void { } // Divider - builder.styled("│", pkgTheme.divider.fg?.rgb); + builder.styled("│", themeFg(pkgTheme.divider)); // Right side const rightText = rightItem ? " " + rightItem.text : ""; @@ -1597,7 +1581,7 @@ function renderPkgManagerUI(): void { } // === BOTTOM SEPARATOR === - builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", pkgTheme.separator.fg?.rgb); + builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", themeFg(pkgTheme.separator)); // === HELP LINE === let helpText = " ↑↓ Navigate Tab Next / Search Enter "; @@ -1613,7 +1597,7 @@ function renderPkgManagerUI(): void { helpText += "Select"; } helpText += " Esc Close\n"; - builder.styled(helpText, pkgTheme.help.fg?.theme ?? pkgTheme.help.fg?.rgb); + builder.styled(helpText, themeFg(pkgTheme.help)); // Build and apply to buffer builder.build(); @@ -1634,13 +1618,11 @@ function buildLeftPanel( ): PanelLine[] { const lines: PanelLine[] = []; const listFocused = pkgState.focus.type === "list"; + const selected = themeColor(pkgTheme.selected); // Installed section if (installedItems.length > 0) { - lines.push({ - text: `INSTALLED (${installedItems.length})`, - fg: pkgTheme.sectionTitle.fg?.theme ?? pkgTheme.sectionTitle.fg?.rgb, - }); + lines.push({ text: `INSTALLED (${installedItems.length})`, fg: themeFg(pkgTheme.sectionTitle) }); let idx = 0; for (const item of installedItems) { @@ -1651,18 +1633,10 @@ function buildLeftPanel( const name = item.name.length > 18 ? item.name.slice(0, 17) + "…" : item.name; const line = `${prefix} ${name.padEnd(18)} ${ver.padEnd(7)} ${status}`; - if (isSelected) { - lines.push({ - text: line, - fg: pkgTheme.selected.fg?.theme ?? pkgTheme.selected.fg?.rgb, - bg: pkgTheme.selected.bg?.theme ?? pkgTheme.selected.bg?.rgb, - }); - } else { - lines.push({ - text: line, - fg: pkgTheme.installed.fg?.theme ?? pkgTheme.installed.fg?.rgb, - }); - } + lines.push(isSelected + ? { text: line, fg: selected.fg, bg: selected.bg } + : { text: line, fg: themeFg(pkgTheme.installed) } + ); idx++; } } @@ -1670,10 +1644,7 @@ function buildLeftPanel( // Available section if (availableItems.length > 0) { if (lines.length > 0) lines.push({ text: "" }); - lines.push({ - text: `AVAILABLE (${availableItems.length})`, - fg: pkgTheme.sectionTitle.fg?.theme ?? pkgTheme.sectionTitle.fg?.rgb, - }); + lines.push({ text: `AVAILABLE (${availableItems.length})`, fg: themeFg(pkgTheme.sectionTitle) }); let idx = installedItems.length; for (const item of availableItems) { @@ -1683,18 +1654,10 @@ function buildLeftPanel( const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name; const line = `${prefix} ${name.padEnd(22)} [${typeTag}]`; - if (isSelected) { - lines.push({ - text: line, - fg: pkgTheme.selected.fg?.theme ?? pkgTheme.selected.fg?.rgb, - bg: pkgTheme.selected.bg?.theme ?? pkgTheme.selected.bg?.rgb, - }); - } else { - lines.push({ - text: line, - fg: pkgTheme.available.fg?.theme ?? pkgTheme.available.fg?.rgb, - }); - } + lines.push(isSelected + ? { text: line, fg: selected.fg, bg: selected.bg } + : { text: line, fg: themeFg(pkgTheme.available) } + ); idx++; } } @@ -1702,12 +1665,12 @@ function buildLeftPanel( // Empty state if (allItems.length === 0) { if (pkgState.isLoading) { - lines.push({ text: "Loading...", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); + lines.push({ text: "Loading...", fg: themeFg(pkgTheme.emptyState) }); } else if (!isRegistrySynced()) { - lines.push({ text: "Registry not synced", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); - lines.push({ text: "Tab to Sync button", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); + lines.push({ text: "Registry not synced", fg: themeFg(pkgTheme.emptyState) }); + lines.push({ text: "Tab to Sync button", fg: themeFg(pkgTheme.emptyState) }); } else { - lines.push({ text: "No packages found", fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb }); + lines.push({ text: "No packages found", fg: themeFg(pkgTheme.emptyState) }); } } @@ -1720,86 +1683,49 @@ function buildRightPanel(selectedItem: PackageListItem | null): PanelLine[] { if (selectedItem) { // Package name - lines.push({ - text: selectedItem.name, - fg: pkgTheme.header.fg?.theme ?? pkgTheme.header.fg?.rgb, - }); - lines.push({ - text: "─".repeat(Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2)), - fg: pkgTheme.separator.fg?.rgb, - }); + lines.push({ text: selectedItem.name, fg: themeFg(pkgTheme.header) }); + lines.push({ text: "─".repeat(Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2)), fg: themeFg(pkgTheme.separator) }); // Version / Author / License let metaLine = `v${selectedItem.version}`; if (selectedItem.author) metaLine += ` • ${selectedItem.author}`; if (selectedItem.license) metaLine += ` • ${selectedItem.license}`; if (metaLine.length > DETAIL_WIDTH - 2) metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "..."; - lines.push({ - text: metaLine, - fg: pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb, - }); + lines.push({ text: metaLine, fg: themeFg(pkgTheme.infoLabel) }); lines.push({ text: "" }); // Description (wrapped) const descText = selectedItem.description || "No description available"; - const descLines = wrapText(descText, DETAIL_WIDTH - 2); - for (const line of descLines) { - lines.push({ - text: line, - fg: pkgTheme.description.fg?.theme ?? pkgTheme.description.fg?.rgb, - }); + for (const line of wrapText(descText, DETAIL_WIDTH - 2)) { + lines.push({ text: line, fg: themeFg(pkgTheme.description) }); } lines.push({ text: "" }); // Keywords if (selectedItem.keywords && selectedItem.keywords.length > 0) { - const kwText = selectedItem.keywords.slice(0, 4).join(", "); - lines.push({ - text: `Tags: ${kwText}`, - fg: pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb, - }); + lines.push({ text: `Tags: ${selectedItem.keywords.slice(0, 4).join(", ")}`, fg: themeFg(pkgTheme.infoLabel) }); lines.push({ text: "" }); } // Repository URL if (selectedItem.repository) { - let displayUrl = selectedItem.repository - .replace(/^https?:\/\//, "") - .replace(/\.git$/, ""); - if (displayUrl.length > DETAIL_WIDTH - 2) { - displayUrl = displayUrl.slice(0, DETAIL_WIDTH - 5) + "..."; - } - lines.push({ - text: displayUrl, - fg: pkgTheme.infoLabel.fg?.theme ?? pkgTheme.infoLabel.fg?.rgb, - }); + let displayUrl = selectedItem.repository.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + if (displayUrl.length > DETAIL_WIDTH - 2) displayUrl = displayUrl.slice(0, DETAIL_WIDTH - 5) + "..."; + lines.push({ text: displayUrl, fg: themeFg(pkgTheme.infoLabel) }); lines.push({ text: "" }); } // Action buttons - const actions = getActionButtons(); - for (let i = 0; i < actions.length; i++) { + for (let i = 0; i < getActionButtons().length; i++) { const focused = isButtonFocused("action", i); - const btn = new ButtonControl(actions[i], focused ? FocusState.Focused : FocusState.Normal); - const btnOutput = btn.render(); - const btnStyle = focused ? pkgTheme.buttonFocused : pkgTheme.button; - lines.push({ - text: btnOutput.text, - fg: btnStyle.fg?.theme ?? btnStyle.fg?.rgb, - bg: btnStyle.bg?.theme ?? btnStyle.bg?.rgb, - }); + const style = themeColor(focused ? pkgTheme.buttonFocused : pkgTheme.button); + lines.push({ text: new ButtonControl(getActionButtons()[i], focused ? FocusState.Focused : FocusState.Normal).render().text, fg: style.fg, bg: style.bg }); } } else { - lines.push({ - text: "Select a package", - fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb, - }); - lines.push({ - text: "to view details", - fg: pkgTheme.emptyState.fg?.theme ?? pkgTheme.emptyState.fg?.rgb, - }); + lines.push({ text: "Select a package", fg: themeFg(pkgTheme.emptyState) }); + lines.push({ text: "to view details", fg: themeFg(pkgTheme.emptyState) }); } return lines; From 1dced51ab28abc57bc16e33e526ed9992088b645 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 12:07:56 +0000 Subject: [PATCH 5/7] refactor(pkg): Simplify help text generation Replace if-else chain with object lookup for help text action labels. Reduces code by 12 lines. https://claude.ai/code/session_01Kt49PHNWUZjs4TNk8zxZZ1 --- crates/fresh-editor/plugins/pkg.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/crates/fresh-editor/plugins/pkg.ts b/crates/fresh-editor/plugins/pkg.ts index 6c9f9227d..0a44f91a7 100644 --- a/crates/fresh-editor/plugins/pkg.ts +++ b/crates/fresh-editor/plugins/pkg.ts @@ -1584,20 +1584,8 @@ function renderPkgManagerUI(): void { builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", themeFg(pkgTheme.separator)); // === HELP LINE === - let helpText = " ↑↓ Navigate Tab Next / Search Enter "; - if (pkgState.focus.type === "action") { - helpText += "Activate"; - } else if (pkgState.focus.type === "filter") { - helpText += "Filter"; - } else if (pkgState.focus.type === "sync") { - helpText += "Sync"; - } else if (pkgState.focus.type === "search") { - helpText += "Search"; - } else { - helpText += "Select"; - } - helpText += " Esc Close\n"; - builder.styled(helpText, themeFg(pkgTheme.help)); + const actionLabel = { action: "Activate", filter: "Filter", sync: "Sync", search: "Search", list: "Select" }[pkgState.focus.type] || "Select"; + builder.styled(` ↑↓ Navigate Tab Next / Search Enter ${actionLabel} Esc Close\n`, themeFg(pkgTheme.help)); // Build and apply to buffer builder.build(); From 9f08ead04e8a91f38787680a5471e5f8caac4df5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 12:12:19 +0000 Subject: [PATCH 6/7] refactor(pkg): Use GroupedListControl for package list - Add renderLines() method to GroupedListControl for split-view layouts - Add customizable colors (titleFg, selectedFg, selectedBg, itemFg) - Replace manual list building in pkg.ts with GroupedListControl - Extract formatPackageItem() function for item rendering This demonstrates proper usage of the controls library, with GroupedListControl handling selection state and rendering while supporting the split-view layout pattern. https://claude.ai/code/session_01Kt49PHNWUZjs4TNk8zxZZ1 --- crates/fresh-editor/plugins/lib/controls.ts | 67 ++++++++++++- crates/fresh-editor/plugins/pkg.ts | 101 ++++++++++---------- 2 files changed, 113 insertions(+), 55 deletions(-) diff --git a/crates/fresh-editor/plugins/lib/controls.ts b/crates/fresh-editor/plugins/lib/controls.ts index e118240d8..eaf5b6f56 100644 --- a/crates/fresh-editor/plugins/lib/controls.ts +++ b/crates/fresh-editor/plugins/lib/controls.ts @@ -420,6 +420,10 @@ export class GroupedListControl { private _maxVisible: number; private _selectionPrefix: string; private _emptyPrefix: string; + private _titleFg: string | RGB; + private _selectedFg: string | RGB; + private _selectedBg: string | RGB; + private _itemFg?: string | RGB; constructor( public groups: ListGroup[], @@ -428,11 +432,23 @@ export class GroupedListControl { maxVisible?: number; selectionPrefix?: string; emptyPrefix?: string; + /** Title foreground color */ + titleFg?: string | RGB; + /** Selected item foreground */ + selectedFg?: string | RGB; + /** Selected item background */ + selectedBg?: string | RGB; + /** Normal item foreground (optional) */ + itemFg?: string | RGB; } = {} ) { this._maxVisible = options.maxVisible ?? 10; this._selectionPrefix = options.selectionPrefix ?? "▸ "; this._emptyPrefix = options.emptyPrefix ?? " "; + this._titleFg = options.titleFg ?? "syntax.keyword"; + this._selectedFg = options.selectedFg ?? "ui.menu_active_fg"; + this._selectedBg = options.selectedBg ?? "ui.menu_active_bg"; + this._itemFg = options.itemFg; } /** @@ -482,7 +498,7 @@ export class GroupedListControl { styles.push({ start: charOffset, end: charOffset + titleLine.length, - fg: "syntax.keyword", + fg: this._titleFg, bold: true, }); charOffset += titleLine.length + 1; @@ -498,8 +514,14 @@ export class GroupedListControl { styles.push({ start: charOffset, end: charOffset + line.length, - fg: "ui.menu_active_fg", - bg: "ui.menu_active_bg", + fg: this._selectedFg, + bg: this._selectedBg, + }); + } else if (this._itemFg) { + styles.push({ + start: charOffset, + end: charOffset + line.length, + fg: this._itemFg, }); } @@ -513,6 +535,45 @@ export class GroupedListControl { styles, }; } + + /** + * Render to individual lines - useful for split-view layouts + * Returns array of line objects with text and optional styling + */ + renderLines(): Array<{ text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean }> { + const result: Array<{ text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean }> = []; + let itemIndex = 0; + + for (const group of this.groups) { + // Blank line between groups + if (result.length > 0) { + result.push({ text: "" }); + } + + // Group title + result.push({ + text: group.title, + fg: this._titleFg, + isTitle: true, + }); + + // Group items + for (const item of group.items) { + const selected = itemIndex === this.selectedIndex; + const prefix = selected ? this._selectionPrefix : this._emptyPrefix; + const line = prefix + this.renderItem(item, selected, itemIndex); + + if (selected) { + result.push({ text: line, fg: this._selectedFg, bg: this._selectedBg }); + } else { + result.push({ text: line, fg: this._itemFg }); + } + itemIndex++; + } + } + + return result; + } } // ============================================================================= diff --git a/crates/fresh-editor/plugins/pkg.ts b/crates/fresh-editor/plugins/pkg.ts index 0a44f91a7..2d2f83252 100644 --- a/crates/fresh-editor/plugins/pkg.ts +++ b/crates/fresh-editor/plugins/pkg.ts @@ -23,8 +23,11 @@ import { Finder } from "./lib/finder.ts"; import { ButtonControl, FocusState, + GroupedListControl, + TextInputControl, VirtualBufferBuilder, } from "./lib/index.ts"; +import type { ListGroup } from "./lib/index.ts"; const editor = getEditor(); @@ -1598,71 +1601,65 @@ interface PanelLine { bg?: string | [number, number, number]; } -/** Build the left panel (package list) */ +/** Format a package item for the list - handles both installed and available */ +function formatPackageItem(item: PackageListItem, _selected: boolean, _index: number): string { + if (item.installed) { + const status = item.updateAvailable ? "↑" : "✓"; + const ver = item.version.length > 7 ? item.version.slice(0, 6) + "…" : item.version; + const name = item.name.length > 18 ? item.name.slice(0, 17) + "…" : item.name; + return `${name.padEnd(18)} ${ver.padEnd(7)} ${status}`; + } else { + const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : "P"; + const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name; + return `${name.padEnd(22)} [${typeTag}]`; + } +} + +/** Build the left panel (package list) using GroupedListControl */ function buildLeftPanel( installedItems: PackageListItem[], availableItems: PackageListItem[], allItems: PackageListItem[] ): PanelLine[] { - const lines: PanelLine[] = []; - const listFocused = pkgState.focus.type === "list"; - const selected = themeColor(pkgTheme.selected); - - // Installed section - if (installedItems.length > 0) { - lines.push({ text: `INSTALLED (${installedItems.length})`, fg: themeFg(pkgTheme.sectionTitle) }); - - let idx = 0; - for (const item of installedItems) { - const isSelected = idx === pkgState.selectedIndex; - const prefix = isSelected && listFocused ? "▸" : " "; - const status = item.updateAvailable ? "↑" : "✓"; - const ver = item.version.length > 7 ? item.version.slice(0, 6) + "…" : item.version; - const name = item.name.length > 18 ? item.name.slice(0, 17) + "…" : item.name; - const line = `${prefix} ${name.padEnd(18)} ${ver.padEnd(7)} ${status}`; - - lines.push(isSelected - ? { text: line, fg: selected.fg, bg: selected.bg } - : { text: line, fg: themeFg(pkgTheme.installed) } - ); - idx++; - } - } - - // Available section - if (availableItems.length > 0) { - if (lines.length > 0) lines.push({ text: "" }); - lines.push({ text: `AVAILABLE (${availableItems.length})`, fg: themeFg(pkgTheme.sectionTitle) }); - - let idx = installedItems.length; - for (const item of availableItems) { - const isSelected = idx === pkgState.selectedIndex; - const prefix = isSelected && listFocused ? "▸" : " "; - const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : "P"; - const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name; - const line = `${prefix} ${name.padEnd(22)} [${typeTag}]`; - - lines.push(isSelected - ? { text: line, fg: selected.fg, bg: selected.bg } - : { text: line, fg: themeFg(pkgTheme.available) } - ); - idx++; - } - } - // Empty state if (allItems.length === 0) { if (pkgState.isLoading) { - lines.push({ text: "Loading...", fg: themeFg(pkgTheme.emptyState) }); + return [{ text: "Loading...", fg: themeFg(pkgTheme.emptyState) }]; } else if (!isRegistrySynced()) { - lines.push({ text: "Registry not synced", fg: themeFg(pkgTheme.emptyState) }); - lines.push({ text: "Tab to Sync button", fg: themeFg(pkgTheme.emptyState) }); + return [ + { text: "Registry not synced", fg: themeFg(pkgTheme.emptyState) }, + { text: "Tab to Sync button", fg: themeFg(pkgTheme.emptyState) }, + ]; } else { - lines.push({ text: "No packages found", fg: themeFg(pkgTheme.emptyState) }); + return [{ text: "No packages found", fg: themeFg(pkgTheme.emptyState) }]; } } - return lines; + const listFocused = pkgState.focus.type === "list"; + const groups: ListGroup[] = []; + + if (installedItems.length > 0) { + groups.push({ title: `INSTALLED (${installedItems.length})`, items: installedItems }); + } + if (availableItems.length > 0) { + groups.push({ title: `AVAILABLE (${availableItems.length})`, items: availableItems }); + } + + const list = new GroupedListControl(groups, formatPackageItem, { + selectionPrefix: listFocused ? "▸ " : " ", + emptyPrefix: " ", + titleFg: themeFg(pkgTheme.sectionTitle), + selectedFg: themeFg(pkgTheme.selected), + selectedBg: themeBg(pkgTheme.selected), + }); + list.selectedIndex = pkgState.selectedIndex; + + // Convert renderLines output to PanelLine format + return list.renderLines().map(line => ({ + text: line.text, + fg: line.fg as PanelLine["fg"], + bg: line.bg as PanelLine["bg"], + })); } /** Build the right panel (package details) */ From 0489e096ecd2f26c4bf1e75c4be5a72317f3eb9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 13:35:56 +0000 Subject: [PATCH 7/7] refactor(pkg): Add SplitView, FilterBar, HelpBar controls Add reusable layout controls to lib/controls.ts: - SplitView: Side-by-side panel rendering with divider - FilterBar: Row of toggle filter buttons with active/focused states - HelpBar: Help text bar with key bindings - PanelLine: Interface for styled lines in panels Refactor pkg.ts to use these new controls, reducing manual rendering code and improving maintainability. Also apply deno fmt formatting fixes across plugin lib files. https://claude.ai/code/session_01Kt49PHNWUZjs4TNk8zxZZ1 --- crates/fresh-editor/plugins/lib/controls.ts | 319 ++- crates/fresh-editor/plugins/lib/finder.ts | 82 +- crates/fresh-editor/plugins/lib/fresh.d.ts | 2329 +++++++++-------- crates/fresh-editor/plugins/lib/index.ts | 52 +- .../plugins/lib/navigation-controller.ts | 18 +- .../fresh-editor/plugins/lib/panel-manager.ts | 15 +- .../fresh-editor/plugins/lib/search-utils.ts | 23 +- crates/fresh-editor/plugins/lib/vbuffer.ts | 46 +- .../plugins/lib/virtual-buffer-factory.ts | 5 +- crates/fresh-editor/plugins/pkg.ts | 809 ++++-- 10 files changed, 2241 insertions(+), 1457 deletions(-) diff --git a/crates/fresh-editor/plugins/lib/controls.ts b/crates/fresh-editor/plugins/lib/controls.ts index eaf5b6f56..36cf39e0d 100644 --- a/crates/fresh-editor/plugins/lib/controls.ts +++ b/crates/fresh-editor/plugins/lib/controls.ts @@ -194,7 +194,11 @@ export class ToggleButton { /** * List item renderer function type */ -export type ItemRenderer = (item: T, selected: boolean, index: number) => string; +export type ItemRenderer = ( + item: T, + selected: boolean, + index: number, +) => string; /** * Selectable list control - mirrors Settings item list behavior @@ -243,7 +247,7 @@ export class ListControl { selectedFg?: string | RGB; /** Selected item background color */ selectedBg?: string | RGB; - } = {} + } = {}, ) { this._maxVisible = options.maxVisible ?? 10; this._selectionPrefix = options.selectionPrefix ?? "▸ "; @@ -257,7 +261,10 @@ export class ListControl { */ selectNext(): void { if (this.items.length === 0) return; - this.selectedIndex = Math.min(this.selectedIndex + 1, this.items.length - 1); + this.selectedIndex = Math.min( + this.selectedIndex + 1, + this.items.length - 1, + ); this.ensureVisible(); } @@ -326,14 +333,15 @@ export class ListControl { const visibleItems = this.items.slice( this.scrollOffset, - this.scrollOffset + this._maxVisible + this.scrollOffset + this._maxVisible, ); for (let i = 0; i < visibleItems.length; i++) { const actualIndex = this.scrollOffset + i; const selected = actualIndex === this.selectedIndex; const prefix = selected ? this._selectionPrefix : this._emptyPrefix; - const line = prefix + this.renderItem(visibleItems[i], selected, actualIndex); + const line = prefix + + this.renderItem(visibleItems[i], selected, actualIndex); lines.push(line); if (selected) { @@ -440,7 +448,7 @@ export class GroupedListControl { selectedBg?: string | RGB; /** Normal item foreground (optional) */ itemFg?: string | RGB; - } = {} + } = {}, ) { this._maxVisible = options.maxVisible ?? 10; this._selectionPrefix = options.selectionPrefix ?? "▸ "; @@ -455,7 +463,7 @@ export class GroupedListControl { * Get all items flattened */ private get allItems(): T[] { - return this.groups.flatMap(g => g.items); + return this.groups.flatMap((g) => g.items); } /** @@ -540,8 +548,12 @@ export class GroupedListControl { * Render to individual lines - useful for split-view layouts * Returns array of line objects with text and optional styling */ - renderLines(): Array<{ text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean }> { - const result: Array<{ text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean }> = []; + renderLines(): Array< + { text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean } + > { + const result: Array< + { text: string; fg?: string | RGB; bg?: string | RGB; isTitle?: boolean } + > = []; let itemIndex = 0; for (const group of this.groups) { @@ -564,7 +576,11 @@ export class GroupedListControl { const line = prefix + this.renderItem(item, selected, itemIndex); if (selected) { - result.push({ text: line, fg: this._selectedFg, bg: this._selectedBg }); + result.push({ + text: line, + fg: this._selectedFg, + bg: this._selectedBg, + }); } else { result.push({ text: line, fg: this._itemFg }); } @@ -602,7 +618,7 @@ export class FocusManager { constructor( /** Ordered list of focusable elements */ - public elements: T[] + public elements: T[], ) {} /** @@ -633,7 +649,8 @@ export class FocusManager { */ focusPrev(): T | undefined { if (this.elements.length === 0) return undefined; - this.currentIndex = (this.currentIndex + this.elements.length - 1) % this.elements.length; + this.currentIndex = (this.currentIndex + this.elements.length - 1) % + this.elements.length; return this.current(); } @@ -716,7 +733,8 @@ export class TextInputControl { * Insert text at cursor position */ insert(text: string): void { - this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.value = this.value.slice(0, this.cursor) + text + + this.value.slice(this.cursor); this.cursor += text.length; } @@ -725,7 +743,8 @@ export class TextInputControl { */ backspace(): void { if (this.cursor > 0) { - this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor); + this.value = this.value.slice(0, this.cursor - 1) + + this.value.slice(this.cursor); this.cursor--; } } @@ -735,7 +754,8 @@ export class TextInputControl { */ delete(): void { if (this.cursor < this.value.length) { - this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1); + this.value = this.value.slice(0, this.cursor) + + this.value.slice(this.cursor + 1); } } @@ -844,3 +864,272 @@ export class Label { return { text: this.text, styles }; } } + +// ============================================================================= +// Panel Line (for split views) +// ============================================================================= + +/** + * A line in a panel with optional styling + */ +export interface PanelLine { + text: string; + fg?: string | RGB; + bg?: string | RGB; +} + +// ============================================================================= +// Split View +// ============================================================================= + +/** + * Renders two panels side-by-side with a divider + * + * @example + * ```typescript + * const split = new SplitView(leftLines, rightLines, { + * leftWidth: 40, + * divider: "│", + * dividerFg: "ui.border", + * minRows: 8, + * }); + * const { text, styles } = split.render(); + * ``` + */ +export class SplitView { + constructor( + public leftLines: PanelLine[], + public rightLines: PanelLine[], + public options: { + /** Width of left panel (right panel takes remaining space) */ + leftWidth: number; + /** Divider character (default: "│") */ + divider?: string; + /** Divider foreground color */ + dividerFg?: string | RGB; + /** Minimum number of rows to render */ + minRows?: number; + /** Left padding for left panel */ + leftPadding?: string; + /** Left padding for right panel */ + rightPadding?: string; + }, + ) {} + + render(): ControlOutput { + const { + leftWidth, + divider = "│", + dividerFg = "ui.border", + minRows = 0, + leftPadding = " ", + rightPadding = " ", + } = this.options; + + const lines: string[] = []; + const styles: StyleRange[] = []; + let charOffset = 0; + + const maxRows = Math.max( + this.leftLines.length, + this.rightLines.length, + minRows, + ); + + for (let i = 0; i < maxRows; i++) { + const leftItem = this.leftLines[i]; + const rightItem = this.rightLines[i]; + + // Left side (padded to fixed width) + const leftContent = leftItem ? (leftPadding + leftItem.text) : ""; + const leftText = leftContent.padEnd(leftWidth); + + if (leftItem?.fg || leftItem?.bg) { + styles.push({ + start: charOffset, + end: charOffset + leftText.length, + fg: leftItem.fg, + bg: leftItem.bg, + }); + } + charOffset += leftText.length; + + // Divider + styles.push({ + start: charOffset, + end: charOffset + divider.length, + fg: dividerFg, + }); + charOffset += divider.length; + + // Right side + const rightText = rightItem ? (rightPadding + rightItem.text) : ""; + + if (rightItem?.fg || rightItem?.bg) { + styles.push({ + start: charOffset, + end: charOffset + rightText.length, + fg: rightItem.fg, + bg: rightItem.bg, + }); + } + charOffset += rightText.length; + + lines.push(leftText + divider + rightText); + charOffset += 1; // newline + } + + return { + text: lines.join("\n"), + styles, + }; + } +} + +// ============================================================================= +// Filter Bar +// ============================================================================= + +/** + * Filter option for FilterBar + */ +export interface FilterOption { + id: string; + label: string; +} + +/** + * Renders a row of toggle filter buttons + * + * @example + * ```typescript + * const filterBar = new FilterBar( + * [{ id: "all", label: "All" }, { id: "active", label: "Active" }], + * "all", // active filter + * 1, // focused index (or -1 for none) + * ); + * const { text, styles } = filterBar.render(); + * ``` + */ +export class FilterBar { + constructor( + public filters: FilterOption[], + public activeId: string, + public focusedIndex: number = -1, + public options: { + activeFg?: string | RGB; + activeBg?: string | RGB; + inactiveFg?: string | RGB; + focusedFg?: string | RGB; + focusedBg?: string | RGB; + } = {}, + ) {} + + render(): ControlOutput { + const { + activeFg = [255, 255, 255], + activeBg = "syntax.keyword", + inactiveFg = [160, 160, 170], + focusedFg = [255, 255, 255], + focusedBg = [80, 80, 90], + } = this.options; + + let text = ""; + const styles: StyleRange[] = []; + + for (let i = 0; i < this.filters.length; i++) { + const f = this.filters[i]; + const isActive = f.id === this.activeId; + const isFocused = i === this.focusedIndex; + + const btn = new ButtonControl( + f.label, + isFocused ? FocusState.Focused : FocusState.Normal, + ); + const btnText = btn.render().text; + + const start = text.length; + + if (isFocused) { + styles.push({ + start, + end: start + btnText.length, + fg: isActive ? activeFg : focusedFg, + bg: isActive ? activeBg : focusedBg, + }); + } else if (isActive) { + styles.push({ + start, + end: start + btnText.length, + fg: activeFg, + bg: activeBg, + }); + } else { + styles.push({ + start, + end: start + btnText.length, + fg: inactiveFg, + }); + } + + text += btnText; + } + + return { text, styles }; + } +} + +// ============================================================================= +// Help Bar +// ============================================================================= + +/** + * Key binding for HelpBar + */ +export interface KeyBinding { + key: string; + action: string; +} + +/** + * Renders a help bar with key bindings + * + * @example + * ```typescript + * const help = new HelpBar([ + * { key: "↑↓", action: "Navigate" }, + * { key: "Tab", action: "Next" }, + * { key: "Enter", action: "Select" }, + * { key: "Esc", action: "Close" }, + * ]); + * const { text, styles } = help.render(); + * // text: " ↑↓ Navigate Tab Next Enter Select Esc Close" + * ``` + */ +export class HelpBar { + constructor( + public bindings: KeyBinding[], + public options: { + fg?: string | RGB; + separator?: string; + prefix?: string; + } = {}, + ) {} + + render(): ControlOutput { + const { fg = "syntax.comment", separator = " ", prefix = " " } = + this.options; + + const text = prefix + + this.bindings.map((b) => `${b.key} ${b.action}`).join(separator); + + return { + text, + styles: [{ + start: 0, + end: text.length, + fg, + }], + }; + } +} diff --git a/crates/fresh-editor/plugins/lib/finder.ts b/crates/fresh-editor/plugins/lib/finder.ts index 76d3273d0..3ce240007 100644 --- a/crates/fresh-editor/plugins/lib/finder.ts +++ b/crates/fresh-editor/plugins/lib/finder.ts @@ -239,7 +239,7 @@ export function defaultFuzzyFilter( items: T[], query: string, format: (item: T, index: number) => DisplayEntry, - maxResults: number = 100 + maxResults: number = 100, ): T[] { if (query === "" || query.trim() === "") { return items.slice(0, maxResults); @@ -296,7 +296,7 @@ export function parseGrepLine(line: string): { */ export function parseGrepOutput( stdout: string, - maxResults: number = 100 + maxResults: number = 100, ): Array<{ file: string; line: number; column: number; content: string }> { const results: Array<{ file: string; @@ -462,10 +462,12 @@ export class Finder { this.editor.startPromptWithInitial( options.title, this.config.id, - options.initialQuery + options.initialQuery, ); } else { - this.editor.debug(`[Finder] calling startPrompt with title="${options.title}", id="${this.config.id}"`); + this.editor.debug( + `[Finder] calling startPrompt with title="${options.title}", id="${this.config.id}"`, + ); const result = this.editor.startPrompt(options.title, this.config.id); this.editor.debug(`[Finder] startPrompt returned: ${result}`); } @@ -598,7 +600,10 @@ export class Finder { // Register event handlers this.editor.on("prompt_changed", `${this.handlerPrefix}_changed`); - this.editor.on("prompt_selection_changed", `${this.handlerPrefix}_selection`); + this.editor.on( + "prompt_selection_changed", + `${this.handlerPrefix}_selection`, + ); this.editor.on("prompt_confirmed", `${this.handlerPrefix}_confirmed`); this.editor.on("prompt_cancelled", `${this.handlerPrefix}_cancelled`); } @@ -624,7 +629,7 @@ export class Finder { this.allItems, query, this.config.format, - this.config.maxResults + this.config.maxResults, ); } @@ -649,7 +654,7 @@ export class Finder { private async runSearch( query: string, - source: SearchSource + source: SearchSource, ): Promise { const debounceMs = source.debounceMs ?? 150; const minQueryLength = source.minQueryLength ?? 2; @@ -711,7 +716,7 @@ export class Finder { // Parse as grep output by default const parsed = parseGrepOutput( result.stdout, - this.config.maxResults + this.config.maxResults, ) as unknown as T[]; this.updatePromptResults(parsed); @@ -772,7 +777,7 @@ export class Finder { description: entry.description, value: `${i}`, disabled: false, - }) + }), ); this.editor.setPromptSuggestions(suggestions); @@ -810,10 +815,10 @@ export class Finder { this.editor.openFile( entry.location.file, entry.location.line, - entry.location.column + entry.location.column, ); this.editor.setStatus( - `Opened ${entry.location.file}:${entry.location.line}` + `Opened ${entry.location.file}:${entry.location.line}`, ); } } else { @@ -883,13 +888,18 @@ export class Finder { const contextLines = this.getContextLines(); const startLine = Math.max(0, entry.location.line - 1 - contextLines); - const endLine = Math.min(lines.length, entry.location.line + contextLines); + const endLine = Math.min( + lines.length, + entry.location.line + contextLines, + ); const entries: TextPropertyEntry[] = []; // Header entries.push({ - text: ` ${entry.location.file}:${entry.location.line}:${entry.location.column ?? 1}\n`, + text: ` ${entry.location.file}:${entry.location.line}:${ + entry.location.column ?? 1 + }\n`, properties: { type: "header" }, }); entries.push({ @@ -920,7 +930,7 @@ export class Finder { this.previewModeName, "special", [["q", "close_buffer"]], - true + true, ); // Create preview split @@ -945,7 +955,10 @@ export class Finder { } } else { // Update existing preview - this.editor.setVirtualBufferContent(this.previewState.bufferId, entries); + this.editor.setVirtualBufferContent( + this.previewState.bufferId, + entries, + ); } } catch (e) { this.editor.debug(`[Finder] Failed to update preview: ${e}`); @@ -978,7 +991,7 @@ export class Finder { ["Return", `${this.handlerPrefix}_panel_select`], ["Escape", `${this.handlerPrefix}_panel_close`], ], - true + true, ); // Select handler @@ -1014,7 +1027,7 @@ export class Finder { const itemIndex = self.panelState.lineToItemIndex.get(data.line); if (itemIndex !== undefined && itemIndex < self.panelState.items.length) { self.editor.setStatus( - `Item ${itemIndex + 1}/${self.panelState.items.length}` + `Item ${itemIndex + 1}/${self.panelState.items.length}`, ); } }; @@ -1064,7 +1077,9 @@ export class Finder { try { const result = await this.editor.createVirtualBufferInSplit({ - name: `*${this.config.id.charAt(0).toUpperCase() + this.config.id.slice(1)}*`, + name: `*${ + this.config.id.charAt(0).toUpperCase() + this.config.id.slice(1) + }*`, mode: this.modeName, readOnly: true, entries, @@ -1082,7 +1097,9 @@ export class Finder { this.applyPanelHighlighting(); const count = this.panelState.items.length; - this.editor.setStatus(`${title}: ${count} item${count !== 1 ? "s" : ""}`); + this.editor.setStatus( + `${title}: ${count} item${count !== 1 ? "s" : ""}`, + ); } else { this.editor.setStatus("Failed to open panel"); } @@ -1180,16 +1197,15 @@ export class Finder { } private buildItemEntry(entry: DisplayEntry): TextPropertyEntry { - const severityIcon = - entry.severity === "error" - ? "[E]" - : entry.severity === "warning" - ? "[W]" - : entry.severity === "info" - ? "[I]" - : entry.severity === "hint" - ? "[H]" - : ""; + const severityIcon = entry.severity === "error" + ? "[E]" + : entry.severity === "warning" + ? "[W]" + : entry.severity === "info" + ? "[I]" + : entry.severity === "hint" + ? "[H]" + : ""; const prefix = severityIcon ? `${severityIcon} ` : " "; const desc = entry.description ? ` ${entry.description}` : ""; @@ -1220,7 +1236,7 @@ export class Finder { private onPanelSelect(): void { const itemIndex = this.panelState.lineToItemIndex.get( - this.panelState.cursorLine + this.panelState.cursorLine, ); if (itemIndex === undefined) { this.editor.setStatus("No item selected"); @@ -1240,10 +1256,10 @@ export class Finder { this.editor.openFile( entry.location.file, entry.location.line, - entry.location.column + entry.location.column, ); this.editor.setStatus( - `Jumped to ${entry.location.file}:${entry.location.line}` + `Jumped to ${entry.location.file}:${entry.location.line}`, ); } } @@ -1416,7 +1432,7 @@ export function getRelativePath(editor: EditorAPI, filePath: string): string { * Create a simple live provider from a getter function */ export function createLiveProvider( - getItems: () => T[] + getItems: () => T[], ): FinderProvider & { notify: () => void } { const listeners: Array<() => void> = []; diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts index 5f4de9a8a..baf646ffa 100644 --- a/crates/fresh-editor/plugins/lib/fresh.d.ts +++ b/crates/fresh-editor/plugins/lib/fresh.d.ts @@ -1,1191 +1,1302 @@ /** -* Fresh Editor TypeScript Plugin API -* -* This file provides type definitions for the Fresh editor's TypeScript plugin system. -* Plugins have access to the global `editor` object which provides methods to: -* - Query editor state (buffers, cursors, viewports) -* - Modify buffer content (insert, delete text) -* - Add visual decorations (overlays, highlighting) -* - Interact with the editor UI (status messages, prompts) -* -* AUTO-GENERATED FILE - DO NOT EDIT MANUALLY -* Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl -*/ + * Fresh Editor TypeScript Plugin API + * + * This file provides type definitions for the Fresh editor's TypeScript plugin system. + * Plugins have access to the global `editor` object which provides methods to: + * - Query editor state (buffers, cursors, viewports) + * - Modify buffer content (insert, delete text) + * - Add visual decorations (overlays, highlighting) + * - Interact with the editor UI (status messages, prompts) + * + * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY + * Generated by fresh-plugin-api-macros + ts-rs from JsEditorApi impl + */ /** -* Get the editor API instance. -* Plugins must call this at the top of their file to get a scoped editor object. -*/ + * Get the editor API instance. + * Plugins must call this at the top of their file to get a scoped editor object. + */ declare function getEditor(): EditorAPI; /** Handle for a cancellable async operation */ interface ProcessHandle extends PromiseLike { - /** Promise that resolves to the result when complete */ - readonly result: Promise; - /** Cancel/kill the operation. Returns true if cancelled, false if already completed */ - kill(): Promise; + /** Promise that resolves to the result when complete */ + readonly result: Promise; + /** Cancel/kill the operation. Returns true if cancelled, false if already completed */ + kill(): Promise; } /** Buffer identifier */ type BufferId = number; /** Split identifier */ type SplitId = number; type TextPropertyEntry = { - /** - * Text content for this entry - */ - text: string; - /** - * Optional properties attached to this text (e.g., file path, line number) - */ - properties?: Record; + /** + * Text content for this entry + */ + text: string; + /** + * Optional properties attached to this text (e.g., file path, line number) + */ + properties?: Record; }; type TsCompositeLayoutConfig = { - /** - * Layout type: "side-by-side", "stacked", or "unified" - */ - type: string; - /** - * Width ratios for side-by-side (e.g., [0.5, 0.5]) - */ - ratios: Array | null; - /** - * Show separator between panes - */ - showSeparator: boolean; - /** - * Spacing for stacked layout - */ - spacing: number | null; + /** + * Layout type: "side-by-side", "stacked", or "unified" + */ + type: string; + /** + * Width ratios for side-by-side (e.g., [0.5, 0.5]) + */ + ratios: Array | null; + /** + * Show separator between panes + */ + showSeparator: boolean; + /** + * Spacing for stacked layout + */ + spacing: number | null; }; type TsCompositeSourceConfig = { - /** - * Buffer ID of the source buffer (required) - */ - bufferId: number; - /** - * Label for this pane (e.g., "OLD", "NEW") - */ - label: string; - /** - * Whether this pane is editable - */ - editable: boolean; - /** - * Style configuration - */ - style: TsCompositePaneStyle | null; + /** + * Buffer ID of the source buffer (required) + */ + bufferId: number; + /** + * Label for this pane (e.g., "OLD", "NEW") + */ + label: string; + /** + * Whether this pane is editable + */ + editable: boolean; + /** + * Style configuration + */ + style: TsCompositePaneStyle | null; }; type TsCompositePaneStyle = { - /** - * Background color for added lines (RGB) - * Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility - */ - addBg: [number, number, number] | null; - /** - * Background color for removed lines (RGB) - */ - removeBg: [number, number, number] | null; - /** - * Background color for modified lines (RGB) - */ - modifyBg: [number, number, number] | null; - /** - * Gutter style: "line-numbers", "diff-markers", "both", or "none" - */ - gutterStyle: string | null; + /** + * Background color for added lines (RGB) + * Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility + */ + addBg: [number, number, number] | null; + /** + * Background color for removed lines (RGB) + */ + removeBg: [number, number, number] | null; + /** + * Background color for modified lines (RGB) + */ + modifyBg: [number, number, number] | null; + /** + * Gutter style: "line-numbers", "diff-markers", "both", or "none" + */ + gutterStyle: string | null; }; type TsCompositeHunk = { - /** - * Starting line in old buffer (0-indexed) - */ - oldStart: number; - /** - * Number of lines in old buffer - */ - oldCount: number; - /** - * Starting line in new buffer (0-indexed) - */ - newStart: number; - /** - * Number of lines in new buffer - */ - newCount: number; + /** + * Starting line in old buffer (0-indexed) + */ + oldStart: number; + /** + * Number of lines in old buffer + */ + oldCount: number; + /** + * Starting line in new buffer (0-indexed) + */ + newStart: number; + /** + * Number of lines in new buffer + */ + newCount: number; }; type TsCreateCompositeBufferOptions = { - /** - * Buffer name (displayed in tabs/title) - */ - name: string; - /** - * Mode for keybindings - */ - mode: string; - /** - * Layout configuration - */ - layout: TsCompositeLayoutConfig; - /** - * Source pane configurations - */ - sources: Array; - /** - * Diff hunks for alignment (optional) - */ - hunks: Array | null; + /** + * Buffer name (displayed in tabs/title) + */ + name: string; + /** + * Mode for keybindings + */ + mode: string; + /** + * Layout configuration + */ + layout: TsCompositeLayoutConfig; + /** + * Source pane configurations + */ + sources: Array; + /** + * Diff hunks for alignment (optional) + */ + hunks: Array | null; }; type ViewportInfo = { - /** - * Byte position of the first visible line - */ - topByte: number; - /** - * Left column offset (horizontal scroll) - */ - leftColumn: number; - /** - * Viewport width - */ - width: number; - /** - * Viewport height - */ - height: number; + /** + * Byte position of the first visible line + */ + topByte: number; + /** + * Left column offset (horizontal scroll) + */ + leftColumn: number; + /** + * Viewport width + */ + width: number; + /** + * Viewport height + */ + height: number; }; type LayoutHints = { - /** - * Optional compose width for centering/wrapping - */ - composeWidth: number | null; - /** - * Optional column guides for aligned tables - */ - columnGuides: Array | null; + /** + * Optional compose width for centering/wrapping + */ + composeWidth: number | null; + /** + * Optional column guides for aligned tables + */ + columnGuides: Array | null; }; type ViewTokenWire = { - /** - * Source byte offset in the buffer. None for injected content (annotations). - */ - source_offset: number | null; - /** - * The token content - */ - kind: ViewTokenWireKind; - /** - * Optional styling for injected content (only used when source_offset is None) - */ - style?: ViewTokenStyle; -}; -type ViewTokenWireKind = { - "Text": string; -} | "Newline" | "Space" | "Break" | { - "BinaryByte": number; + /** + * Source byte offset in the buffer. None for injected content (annotations). + */ + source_offset: number | null; + /** + * The token content + */ + kind: ViewTokenWireKind; + /** + * Optional styling for injected content (only used when source_offset is None) + */ + style?: ViewTokenStyle; }; +type ViewTokenWireKind = + | { + "Text": string; + } + | "Newline" + | "Space" + | "Break" + | { + "BinaryByte": number; + }; type ViewTokenStyle = { - /** - * Foreground color as RGB tuple - */ - fg: [number, number, number] | null; - /** - * Background color as RGB tuple - */ - bg: [number, number, number] | null; - /** - * Whether to render in bold - */ - bold: boolean; - /** - * Whether to render in italic - */ - italic: boolean; + /** + * Foreground color as RGB tuple + */ + fg: [number, number, number] | null; + /** + * Background color as RGB tuple + */ + bg: [number, number, number] | null; + /** + * Whether to render in bold + */ + bold: boolean; + /** + * Whether to render in italic + */ + italic: boolean; }; type PromptSuggestion = { - /** - * The text to display - */ - text: string; - /** - * Optional description - */ - description?: string; - /** - * The value to use when selected (defaults to text if None) - */ - value?: string; - /** - * Whether this suggestion is disabled (greyed out, defaults to false) - */ - disabled?: boolean; - /** - * Optional keyboard shortcut - */ - keybinding?: string; + /** + * The text to display + */ + text: string; + /** + * Optional description + */ + description?: string; + /** + * The value to use when selected (defaults to text if None) + */ + value?: string; + /** + * Whether this suggestion is disabled (greyed out, defaults to false) + */ + disabled?: boolean; + /** + * Optional keyboard shortcut + */ + keybinding?: string; }; type DirEntry = { - /** - * File/directory name - */ - name: string; - /** - * True if this is a file - */ - is_file: boolean; - /** - * True if this is a directory - */ - is_dir: boolean; + /** + * File/directory name + */ + name: string; + /** + * True if this is a file + */ + is_file: boolean; + /** + * True if this is a directory + */ + is_dir: boolean; }; type BufferInfo = { - /** - * Buffer ID - */ - id: number; - /** - * File path (if any) - */ - path: string; - /** - * Whether the buffer has been modified - */ - modified: boolean; - /** - * Length of buffer in bytes - */ - length: number; + /** + * Buffer ID + */ + id: number; + /** + * File path (if any) + */ + path: string; + /** + * Whether the buffer has been modified + */ + modified: boolean; + /** + * Length of buffer in bytes + */ + length: number; }; type JsDiagnostic = { - /** - * Document URI - */ - uri: string; - /** - * Diagnostic message - */ - message: string; - /** - * Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown - */ - severity: number | null; - /** - * Range in the document - */ - range: JsRange; - /** - * Source of the diagnostic (e.g., "typescript", "eslint") - */ - source?: string; + /** + * Document URI + */ + uri: string; + /** + * Diagnostic message + */ + message: string; + /** + * Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown + */ + severity: number | null; + /** + * Range in the document + */ + range: JsRange; + /** + * Source of the diagnostic (e.g., "typescript", "eslint") + */ + source?: string; }; type JsRange = { - /** - * Start position - */ - start: JsPosition; - /** - * End position - */ - end: JsPosition; + /** + * Start position + */ + start: JsPosition; + /** + * End position + */ + end: JsPosition; }; type JsPosition = { - /** - * Zero-indexed line number - */ - line: number; - /** - * Zero-indexed character offset - */ - character: number; + /** + * Zero-indexed line number + */ + line: number; + /** + * Zero-indexed character offset + */ + character: number; }; type ActionSpec = { - /** - * Action name (e.g., "move_word_right", "delete_line") - */ - action: string; - /** - * Number of times to repeat the action (default 1) - */ - count: number; + /** + * Action name (e.g., "move_word_right", "delete_line") + */ + action: string; + /** + * Number of times to repeat the action (default 1) + */ + count: number; }; type TsActionPopupAction = { - /** - * Unique action identifier (returned in ActionPopupResult) - */ - id: string; - /** - * Display text for the button (can include command hints) - */ - label: string; + /** + * Unique action identifier (returned in ActionPopupResult) + */ + id: string; + /** + * Display text for the button (can include command hints) + */ + label: string; }; type ActionPopupOptions = { - /** - * Unique identifier for the popup (used in ActionPopupResult) - */ - id: string; - /** - * Title text for the popup - */ - title: string; - /** - * Body message (supports basic formatting) - */ - message: string; - /** - * Action buttons to display - */ - actions: Array; + /** + * Unique identifier for the popup (used in ActionPopupResult) + */ + id: string; + /** + * Title text for the popup + */ + title: string; + /** + * Body message (supports basic formatting) + */ + message: string; + /** + * Action buttons to display + */ + actions: Array; }; type FileExplorerDecoration = { - /** - * File path to decorate - */ - path: string; - /** - * Symbol to display (e.g., "●", "M", "A") - */ - symbol: string; - /** - * Color as RGB array (rquickjs_serde requires array, not tuple) - */ - color: [number, number, number]; - /** - * Priority for display when multiple decorations exist (higher wins) - */ - priority: number; + /** + * File path to decorate + */ + path: string; + /** + * Symbol to display (e.g., "●", "M", "A") + */ + symbol: string; + /** + * Color as RGB array (rquickjs_serde requires array, not tuple) + */ + color: [number, number, number]; + /** + * Priority for display when multiple decorations exist (higher wins) + */ + priority: number; }; type FormatterPackConfig = { - /** - * Command to run (e.g., "prettier", "rustfmt") - */ - command: string; - /** - * Arguments to pass to the formatter - */ - args: Array; + /** + * Command to run (e.g., "prettier", "rustfmt") + */ + command: string; + /** + * Arguments to pass to the formatter + */ + args: Array; }; type BackgroundProcessResult = { - /** - * Unique process ID for later reference - */ - process_id: number; - /** - * Process exit code (0 usually means success, -1 if killed) - * Only present when the process has exited - */ - exit_code: number; + /** + * Unique process ID for later reference + */ + process_id: number; + /** + * Process exit code (0 usually means success, -1 if killed) + * Only present when the process has exited + */ + exit_code: number; }; type BufferSavedDiff = { - equal: boolean; - byte_ranges: Array<[number, number]>; - line_ranges: Array<[number, number]> | null; + equal: boolean; + byte_ranges: Array<[number, number]>; + line_ranges: Array<[number, number]> | null; }; type TsCompositeHunk = { - /** - * Starting line in old buffer (0-indexed) - */ - oldStart: number; - /** - * Number of lines in old buffer - */ - oldCount: number; - /** - * Starting line in new buffer (0-indexed) - */ - newStart: number; - /** - * Number of lines in new buffer - */ - newCount: number; + /** + * Starting line in old buffer (0-indexed) + */ + oldStart: number; + /** + * Number of lines in old buffer + */ + oldCount: number; + /** + * Starting line in new buffer (0-indexed) + */ + newStart: number; + /** + * Number of lines in new buffer + */ + newCount: number; }; type TsCreateCompositeBufferOptions = { - /** - * Buffer name (displayed in tabs/title) - */ - name: string; - /** - * Mode for keybindings - */ - mode: string; - /** - * Layout configuration - */ - layout: TsCompositeLayoutConfig; - /** - * Source pane configurations - */ - sources: Array; - /** - * Diff hunks for alignment (optional) - */ - hunks: Array | null; + /** + * Buffer name (displayed in tabs/title) + */ + name: string; + /** + * Mode for keybindings + */ + mode: string; + /** + * Layout configuration + */ + layout: TsCompositeLayoutConfig; + /** + * Source pane configurations + */ + sources: Array; + /** + * Diff hunks for alignment (optional) + */ + hunks: Array | null; }; type CreateVirtualBufferInExistingSplitOptions = { - /** - * Buffer name (displayed in tabs/title) - */ - name: string; - /** - * Target split ID (required) - */ - splitId: number; - /** - * Mode for keybindings (e.g., "git-log", "search-results") - */ - mode?: string; - /** - * Whether buffer is read-only (default: false) - */ - readOnly?: boolean; - /** - * Show line numbers in gutter (default: true) - */ - showLineNumbers?: boolean; - /** - * Show cursor (default: true) - */ - showCursors?: boolean; - /** - * Disable text editing (default: false) - */ - editingDisabled?: boolean; - /** - * Enable line wrapping - */ - lineWrap?: boolean; - /** - * Initial content entries with optional properties - */ - entries?: Array; + /** + * Buffer name (displayed in tabs/title) + */ + name: string; + /** + * Target split ID (required) + */ + splitId: number; + /** + * Mode for keybindings (e.g., "git-log", "search-results") + */ + mode?: string; + /** + * Whether buffer is read-only (default: false) + */ + readOnly?: boolean; + /** + * Show line numbers in gutter (default: true) + */ + showLineNumbers?: boolean; + /** + * Show cursor (default: true) + */ + showCursors?: boolean; + /** + * Disable text editing (default: false) + */ + editingDisabled?: boolean; + /** + * Enable line wrapping + */ + lineWrap?: boolean; + /** + * Initial content entries with optional properties + */ + entries?: Array; }; type CreateVirtualBufferInSplitOptions = { - /** - * Buffer name (displayed in tabs/title) - */ - name: string; - /** - * Mode for keybindings (e.g., "git-log", "search-results") - */ - mode?: string; - /** - * Whether buffer is read-only (default: false) - */ - readOnly?: boolean; - /** - * Split ratio 0.0-1.0 (default: 0.5) - */ - ratio?: number; - /** - * Split direction: "horizontal" or "vertical" - */ - direction?: string; - /** - * Panel ID to split from - */ - panelId?: string; - /** - * Show line numbers in gutter (default: true) - */ - showLineNumbers?: boolean; - /** - * Show cursor (default: true) - */ - showCursors?: boolean; - /** - * Disable text editing (default: false) - */ - editingDisabled?: boolean; - /** - * Enable line wrapping - */ - lineWrap?: boolean; - /** - * Initial content entries with optional properties - */ - entries?: Array; + /** + * Buffer name (displayed in tabs/title) + */ + name: string; + /** + * Mode for keybindings (e.g., "git-log", "search-results") + */ + mode?: string; + /** + * Whether buffer is read-only (default: false) + */ + readOnly?: boolean; + /** + * Split ratio 0.0-1.0 (default: 0.5) + */ + ratio?: number; + /** + * Split direction: "horizontal" or "vertical" + */ + direction?: string; + /** + * Panel ID to split from + */ + panelId?: string; + /** + * Show line numbers in gutter (default: true) + */ + showLineNumbers?: boolean; + /** + * Show cursor (default: true) + */ + showCursors?: boolean; + /** + * Disable text editing (default: false) + */ + editingDisabled?: boolean; + /** + * Enable line wrapping + */ + lineWrap?: boolean; + /** + * Initial content entries with optional properties + */ + entries?: Array; }; type CreateVirtualBufferOptions = { - /** - * Buffer name (displayed in tabs/title) - */ - name: string; - /** - * Mode for keybindings (e.g., "git-log", "search-results") - */ - mode?: string; - /** - * Whether buffer is read-only (default: false) - */ - readOnly?: boolean; - /** - * Show line numbers in gutter (default: false) - */ - showLineNumbers?: boolean; - /** - * Show cursor (default: true) - */ - showCursors?: boolean; - /** - * Disable text editing (default: false) - */ - editingDisabled?: boolean; - /** - * Hide from tab bar (default: false) - */ - hiddenFromTabs?: boolean; - /** - * Initial content entries with optional properties - */ - entries?: Array; + /** + * Buffer name (displayed in tabs/title) + */ + name: string; + /** + * Mode for keybindings (e.g., "git-log", "search-results") + */ + mode?: string; + /** + * Whether buffer is read-only (default: false) + */ + readOnly?: boolean; + /** + * Show line numbers in gutter (default: false) + */ + showLineNumbers?: boolean; + /** + * Show cursor (default: true) + */ + showCursors?: boolean; + /** + * Disable text editing (default: false) + */ + editingDisabled?: boolean; + /** + * Hide from tab bar (default: false) + */ + hiddenFromTabs?: boolean; + /** + * Initial content entries with optional properties + */ + entries?: Array; }; type LanguagePackConfig = { - /** - * Comment prefix for line comments (e.g., "//" or "#") - */ - commentPrefix: string | null; - /** - * Block comment start marker (e.g., slash-star) - */ - blockCommentStart: string | null; - /** - * Block comment end marker (e.g., star-slash) - */ - blockCommentEnd: string | null; - /** - * Whether to use tabs instead of spaces for indentation - */ - useTabs: boolean | null; - /** - * Tab size (number of spaces per tab level) - */ - tabSize: number | null; - /** - * Whether auto-indent is enabled - */ - autoIndent: boolean | null; - /** - * Whether to show whitespace tab indicators (→) for this language - * Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation. - */ - showWhitespaceTabs: boolean | null; - /** - * Formatter configuration - */ - formatter: FormatterPackConfig | null; + /** + * Comment prefix for line comments (e.g., "//" or "#") + */ + commentPrefix: string | null; + /** + * Block comment start marker (e.g., slash-star) + */ + blockCommentStart: string | null; + /** + * Block comment end marker (e.g., star-slash) + */ + blockCommentEnd: string | null; + /** + * Whether to use tabs instead of spaces for indentation + */ + useTabs: boolean | null; + /** + * Tab size (number of spaces per tab level) + */ + tabSize: number | null; + /** + * Whether auto-indent is enabled + */ + autoIndent: boolean | null; + /** + * Whether to show whitespace tab indicators (→) for this language + * Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation. + */ + showWhitespaceTabs: boolean | null; + /** + * Formatter configuration + */ + formatter: FormatterPackConfig | null; }; type LspServerPackConfig = { - /** - * Command to start the LSP server - */ - command: string; - /** - * Arguments to pass to the command - */ - args: Array; - /** - * Whether to auto-start the server when a matching file is opened - */ - autoStart: boolean | null; - /** - * LSP initialization options - */ - initializationOptions: Record | null; + /** + * Command to start the LSP server + */ + command: string; + /** + * Arguments to pass to the command + */ + args: Array; + /** + * Whether to auto-start the server when a matching file is opened + */ + autoStart: boolean | null; + /** + * LSP initialization options + */ + initializationOptions: Record | null; }; type SpawnResult = { - /** - * Complete stdout as string - */ - stdout: string; - /** - * Complete stderr as string - */ - stderr: string; - /** - * Process exit code (0 usually means success, -1 if killed) - */ - exit_code: number; + /** + * Complete stdout as string + */ + stdout: string; + /** + * Complete stderr as string + */ + stderr: string; + /** + * Process exit code (0 usually means success, -1 if killed) + */ + exit_code: number; }; type PromptSuggestion = { - /** - * The text to display - */ - text: string; - /** - * Optional description - */ - description?: string; - /** - * The value to use when selected (defaults to text if None) - */ - value?: string; - /** - * Whether this suggestion is disabled (greyed out, defaults to false) - */ - disabled?: boolean; - /** - * Optional keyboard shortcut - */ - keybinding?: string; + /** + * The text to display + */ + text: string; + /** + * Optional description + */ + description?: string; + /** + * The value to use when selected (defaults to text if None) + */ + value?: string; + /** + * Whether this suggestion is disabled (greyed out, defaults to false) + */ + disabled?: boolean; + /** + * Optional keyboard shortcut + */ + keybinding?: string; }; type TextPropertiesAtCursor = Array>; type TsHighlightSpan = { - start: number; - end: number; - color: [number, number, number]; - bold: boolean; - italic: boolean; + start: number; + end: number; + color: [number, number, number]; + bold: boolean; + italic: boolean; }; type VirtualBufferResult = { - /** - * The created buffer ID - */ - bufferId: number; - /** - * The split ID (if created in a new split) - */ - splitId: number | null; + /** + * The created buffer ID + */ + bufferId: number; + /** + * The split ID (if created in a new split) + */ + splitId: number | null; }; /** -* Main editor API interface -*/ + * Main editor API interface + */ interface EditorAPI { - /** - * Get the active buffer ID (0 if none) - */ - getActiveBufferId(): number; - /** - * Get the active split ID - */ - getActiveSplitId(): number; - /** - * List all open buffers - returns array of BufferInfo objects - */ - listBuffers(): BufferInfo[]; - debug(msg: string): void; - info(msg: string): void; - warn(msg: string): void; - error(msg: string): void; - setStatus(msg: string): void; - copyToClipboard(text: string): void; - setClipboard(text: string): void; - /** - * Register a command - reads plugin name from __pluginName__ global - * context is optional - can be omitted, null, undefined, or a string - */ - registerCommand(name: string, description: string, handlerName: string, context?: unknown): boolean; - /** - * Unregister a command by name - */ - unregisterCommand(name: string): boolean; - /** - * Set a context (for keybinding conditions) - */ - setContext(name: string, active: boolean): boolean; - /** - * Execute a built-in action - */ - executeAction(actionName: string): boolean; - /** - * Translate a string - reads plugin name from __pluginName__ global - * Args is optional - can be omitted, undefined, null, or an object - */ - t(key: string, ...args: unknown[]): string; - /** - * Get cursor position in active buffer - */ - getCursorPosition(): number; - /** - * Get file path for a buffer - */ - getBufferPath(bufferId: number): string; - /** - * Get buffer length in bytes - */ - getBufferLength(bufferId: number): number; - /** - * Check if buffer has unsaved changes - */ - isBufferModified(bufferId: number): boolean; - /** - * Save a buffer to a specific file path - * Used by :w filename to save unnamed buffers or save-as - */ - saveBufferToPath(bufferId: number, path: string): boolean; - /** - * Get buffer info by ID - */ - getBufferInfo(bufferId: number): BufferInfo | null; - /** - * Get primary cursor info for active buffer - */ - getPrimaryCursor(): unknown; - /** - * Get all cursors for active buffer - */ - getAllCursors(): unknown; - /** - * Get all cursor positions as byte offsets - */ - getAllCursorPositions(): unknown; - /** - * Get viewport info for active buffer - */ - getViewport(): ViewportInfo | null; - /** - * Get the line number (0-indexed) of the primary cursor - */ - getCursorLine(): number; - /** - * Get the byte offset of the start of a line (0-indexed line number) - * Returns null if the line number is out of range - */ - getLineStartPosition(line: number): Promise; - /** - * Find buffer by file path, returns buffer ID or 0 if not found - */ - findBufferByPath(path: string): number; - /** - * Get diff between buffer content and last saved version - */ - getBufferSavedDiff(bufferId: number): BufferSavedDiff | null; - /** - * Insert text at a position in a buffer - */ - insertText(bufferId: number, position: number, text: string): boolean; - /** - * Delete a range from a buffer - */ - deleteRange(bufferId: number, start: number, end: number): boolean; - /** - * Insert text at cursor position in active buffer - */ - insertAtCursor(text: string): boolean; - /** - * Open a file, optionally at a specific line/column - */ - openFile(path: string, line: number | null, column: number | null): boolean; - /** - * Open a file in a specific split - */ - openFileInSplit(splitId: number, path: string, line: number, column: number): boolean; - /** - * Show a buffer in the current split - */ - showBuffer(bufferId: number): boolean; - /** - * Close a buffer - */ - closeBuffer(bufferId: number): boolean; - /** - * Subscribe to an editor event - */ - on(eventName: string, handlerName: string): void; - /** - * Unsubscribe from an event - */ - off(eventName: string, handlerName: string): void; - /** - * Get an environment variable - */ - getEnv(name: string): string | null; - /** - * Get current working directory - */ - getCwd(): string; - /** - * Join path components (variadic - accepts multiple string arguments) - * Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join) - */ - pathJoin(...parts: string[]): string; - /** - * Get directory name from path - */ - pathDirname(path: string): string; - /** - * Get file name from path - */ - pathBasename(path: string): string; - /** - * Get file extension - */ - pathExtname(path: string): string; - /** - * Check if path is absolute - */ - pathIsAbsolute(path: string): boolean; - /** - * Check if file exists - */ - fileExists(path: string): boolean; - /** - * Read file contents - */ - readFile(path: string): string | null; - /** - * Write file contents - */ - writeFile(path: string, content: string): boolean; - /** - * Read directory contents (returns array of {name, is_file, is_dir}) - */ - readDir(path: string): DirEntry[]; - /** - * Get current config as JS object - */ - getConfig(): unknown; - /** - * Get user config as JS object - */ - getUserConfig(): unknown; - /** - * Reload configuration from file - */ - reloadConfig(): void; - /** - * Reload theme registry from disk - * Call this after installing theme packages or saving new themes - */ - reloadThemes(): void; - /** - * Register a TextMate grammar file for a language - * The grammar will be pending until reload_grammars() is called - */ - registerGrammar(language: string, grammarPath: string, extensions: string[]): boolean; - /** - * Register language configuration (comment prefix, indentation, formatter) - */ - registerLanguageConfig(language: string, config: LanguagePackConfig): boolean; - /** - * Register an LSP server for a language - */ - registerLspServer(language: string, config: LspServerPackConfig): boolean; - /** - * Reload the grammar registry to apply registered grammars - * Call this after registering one or more grammars - */ - reloadGrammars(): void; - /** - * Get config directory path - */ - getConfigDir(): string; - /** - * Get themes directory path - */ - getThemesDir(): string; - /** - * Apply a theme by name - */ - applyTheme(themeName: string): boolean; - /** - * Get theme schema as JS object - */ - getThemeSchema(): unknown; - /** - * Get list of builtin themes as JS object - */ - getBuiltinThemes(): unknown; - /** - * Delete a custom theme (alias for deleteThemeSync) - */ - deleteTheme(name: string): boolean; - /** - * Get file stat information - */ - fileStat(path: string): unknown; - /** - * Check if a background process is still running - */ - isProcessRunning(ProcessId: number): boolean; - /** - * Kill a process by ID (alias for killBackgroundProcess) - */ - killProcess(processId: number): boolean; - /** - * Translate a key for a specific plugin - */ - pluginTranslate(pluginName: string, key: string, args?: Record): string; - /** - * Create a composite buffer (async) - * - * Uses typed CreateCompositeBufferOptions - serde validates field names at runtime - * via `deny_unknown_fields` attribute - */ - createCompositeBuffer(opts: CreateCompositeBufferOptions): Promise; - /** - * Update alignment hunks for a composite buffer - * - * Uses typed Vec - serde validates field names at runtime - */ - updateCompositeAlignment(bufferId: number, hunks: CompositeHunk[]): boolean; - /** - * Close a composite buffer - */ - closeCompositeBuffer(bufferId: number): boolean; - /** - * Request syntax highlights for a buffer range (async) - */ - getHighlights(bufferId: number, start: number, end: number): Promise; - /** - * Add an overlay with styling options - * - * Colors can be specified as RGB arrays `[r, g, b]` or theme key strings. - * Theme keys are resolved at render time, so overlays update with theme changes. - * - * Theme key examples: "ui.status_bar_fg", "editor.selection_bg", "syntax.keyword" - * - * Example usage in TypeScript: - * ```typescript - * editor.addOverlay(bufferId, "my-namespace", 0, 10, { - * fg: "syntax.keyword", // theme key - * bg: [40, 40, 50], // RGB array - * bold: true, - * }); - * ``` - */ - addOverlay(bufferId: number, namespace: string, start: number, end: number, options: Record): boolean; - /** - * Clear all overlays in a namespace - */ - clearNamespace(bufferId: number, namespace: string): boolean; - /** - * Clear all overlays from a buffer - */ - clearAllOverlays(bufferId: number): boolean; - /** - * Clear all overlays that overlap with a byte range - */ - clearOverlaysInRange(bufferId: number, start: number, end: number): boolean; - /** - * Remove an overlay by its handle - */ - removeOverlay(bufferId: number, handle: string): boolean; - /** - * Submit a view transform for a buffer/split - * - * Note: tokens should be ViewTokenWire[], layoutHints should be LayoutHints - * These use manual parsing due to complex enum handling - */ - submitViewTransform(bufferId: number, splitId: number | null, start: number, end: number, tokens: Record[], LayoutHints?: Record): boolean; - /** - * Clear view transform for a buffer/split - */ - clearViewTransform(bufferId: number, splitId: number | null): boolean; - /** - * Set file explorer decorations for a namespace - */ - setFileExplorerDecorations(namespace: string, decorations: Record[]): boolean; - /** - * Clear file explorer decorations for a namespace - */ - clearFileExplorerDecorations(namespace: string): boolean; - /** - * Add virtual text (inline text that doesn't exist in the buffer) - */ - addVirtualText(bufferId: number, virtualTextId: string, position: number, text: string, r: number, g: number, b: number, before: boolean, useBg: boolean): boolean; - /** - * Remove a virtual text by ID - */ - removeVirtualText(bufferId: number, virtualTextId: string): boolean; - /** - * Remove virtual texts whose ID starts with the given prefix - */ - removeVirtualTextsByPrefix(bufferId: number, prefix: string): boolean; - /** - * Clear all virtual texts from a buffer - */ - clearVirtualTexts(bufferId: number): boolean; - /** - * Clear all virtual texts in a namespace - */ - clearVirtualTextNamespace(bufferId: number, namespace: string): boolean; - /** - * Add a virtual line (full line above/below a position) - */ - addVirtualLine(bufferId: number, position: number, text: string, fgR: number, fgG: number, fgB: number, bgR: number, bgG: number, bgB: number, above: boolean, namespace: string, priority: number): boolean; - /** - * Show a prompt and wait for user input (async) - * Returns the user input or null if cancelled - */ - prompt(label: string, initialValue: string): Promise; - /** - * Start an interactive prompt - */ - startPrompt(label: string, promptType: string): boolean; - /** - * Start a prompt with initial value - */ - startPromptWithInitial(label: string, promptType: string, initialValue: string): boolean; - /** - * Set suggestions for the current prompt - * - * Uses typed Vec - serde validates field names at runtime - */ - setPromptSuggestions(suggestions: Suggestion[]): boolean; - /** - * Define a buffer mode (takes bindings as array of [key, command] pairs) - */ - defineMode(name: string, parent: string | null, bindingsArr: string[][], readOnly?: boolean): boolean; - /** - * Set the global editor mode - */ - setEditorMode(mode: string | null): boolean; - /** - * Get the current editor mode - */ - getEditorMode(): string | null; - /** - * Close a split - */ - closeSplit(splitId: number): boolean; - /** - * Set the buffer displayed in a split - */ - setSplitBuffer(splitId: number, bufferId: number): boolean; - /** - * Focus a specific split - */ - focusSplit(splitId: number): boolean; - /** - * Set scroll position of a split - */ - setSplitScroll(splitId: number, topByte: number): boolean; - /** - * Set the ratio of a split (0.0 to 1.0, 0.5 = equal) - */ - setSplitRatio(splitId: number, ratio: number): boolean; - /** - * Distribute all splits evenly - */ - distributeSplitsEvenly(): boolean; - /** - * Set cursor position in a buffer - */ - setBufferCursor(bufferId: number, position: number): boolean; - /** - * Set a line indicator in the gutter - */ - setLineIndicator(bufferId: number, line: number, namespace: string, symbol: string, r: number, g: number, b: number, priority: number): boolean; - /** - * Clear line indicators in a namespace - */ - clearLineIndicators(bufferId: number, namespace: string): boolean; - /** - * Enable or disable line numbers for a buffer - */ - setLineNumbers(bufferId: number, enabled: boolean): boolean; - /** - * Create a scroll sync group for anchor-based synchronized scrolling - */ - createScrollSyncGroup(groupId: number, leftSplit: number, rightSplit: number): boolean; - /** - * Set sync anchors for a scroll sync group - */ - setScrollSyncAnchors(groupId: number, anchors: number[][]): boolean; - /** - * Remove a scroll sync group - */ - removeScrollSyncGroup(groupId: number): boolean; - /** - * Execute multiple actions in sequence - * - * Takes typed ActionSpec array - serde validates field names at runtime - */ - executeActions(actions: ActionSpec[]): boolean; - /** - * Show an action popup - * - * Takes a typed ActionPopupOptions struct - serde validates field names at runtime - */ - showActionPopup(opts: ActionPopupOptions): boolean; - /** - * Disable LSP for a specific language - */ - disableLspForLanguage(language: string): boolean; - /** - * Set the workspace root URI for a specific language's LSP server - * This allows plugins to specify project roots (e.g., directory containing .csproj) - */ - setLspRootUri(language: string, uri: string): boolean; - /** - * Get all diagnostics from LSP - */ - getAllDiagnostics(): JsDiagnostic[]; - /** - * Get registered event handlers for an event - */ - getHandlers(eventName: string): string[]; - /** - * Create a virtual buffer in current split (async, returns buffer and split IDs) - */ - createVirtualBuffer(opts: CreateVirtualBufferOptions): Promise; - /** - * Create a virtual buffer in a new split (async, returns buffer and split IDs) - */ - createVirtualBufferInSplit(opts: CreateVirtualBufferInSplitOptions): Promise; - /** - * Create a virtual buffer in an existing split (async, returns buffer and split IDs) - */ - createVirtualBufferInExistingSplit(opts: CreateVirtualBufferInExistingSplitOptions): Promise; - /** - * Set virtual buffer content (takes array of entry objects) - * - * Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support - */ - setVirtualBufferContent(bufferId: number, entriesArr: Record[]): boolean; - /** - * Get text properties at cursor position (returns JS array) - */ - getTextPropertiesAtCursor(bufferId: number): TextPropertiesAtCursor; - /** - * Spawn a process (async, returns request_id) - */ - spawnProcess(command: string, args: string[], cwd?: string): ProcessHandle; - /** - * Wait for a process to complete and get its result (async) - */ - spawnProcessWait(processId: number): Promise; - /** - * Get buffer text range (async, returns request_id) - */ - getBufferText(bufferId: number, start: number, end: number): Promise; - /** - * Delay/sleep (async, returns request_id) - */ - delay(durationMs: number): Promise; - /** - * Send LSP request (async, returns request_id) - */ - sendLspRequest(language: string, method: string, params: Record | null): Promise; - /** - * Spawn a background process (async, returns request_id which is also process_id) - */ - spawnBackgroundProcess(command: string, args: string[], cwd?: string): ProcessHandle; - /** - * Kill a background process - */ - killBackgroundProcess(processId: number): boolean; - /** - * Force refresh of line display - */ - refreshLines(bufferId: number): boolean; - /** - * Get the current locale - */ - getCurrentLocale(): string; - /** - * Load a plugin from a file path (async) - */ - loadPlugin(path: string): Promise; - /** - * Unload a plugin by name (async) - */ - unloadPlugin(name: string): Promise; - /** - * Reload a plugin by name (async) - */ - reloadPlugin(name: string): Promise; - /** - * List all loaded plugins (async) - * Returns array of { name: string, path: string, enabled: boolean } - */ - listPlugins(): Promise>; + /** + * Get the active buffer ID (0 if none) + */ + getActiveBufferId(): number; + /** + * Get the active split ID + */ + getActiveSplitId(): number; + /** + * List all open buffers - returns array of BufferInfo objects + */ + listBuffers(): BufferInfo[]; + debug(msg: string): void; + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; + setStatus(msg: string): void; + copyToClipboard(text: string): void; + setClipboard(text: string): void; + /** + * Register a command - reads plugin name from __pluginName__ global + * context is optional - can be omitted, null, undefined, or a string + */ + registerCommand( + name: string, + description: string, + handlerName: string, + context?: unknown, + ): boolean; + /** + * Unregister a command by name + */ + unregisterCommand(name: string): boolean; + /** + * Set a context (for keybinding conditions) + */ + setContext(name: string, active: boolean): boolean; + /** + * Execute a built-in action + */ + executeAction(actionName: string): boolean; + /** + * Translate a string - reads plugin name from __pluginName__ global + * Args is optional - can be omitted, undefined, null, or an object + */ + t(key: string, ...args: unknown[]): string; + /** + * Get cursor position in active buffer + */ + getCursorPosition(): number; + /** + * Get file path for a buffer + */ + getBufferPath(bufferId: number): string; + /** + * Get buffer length in bytes + */ + getBufferLength(bufferId: number): number; + /** + * Check if buffer has unsaved changes + */ + isBufferModified(bufferId: number): boolean; + /** + * Save a buffer to a specific file path + * Used by :w filename to save unnamed buffers or save-as + */ + saveBufferToPath(bufferId: number, path: string): boolean; + /** + * Get buffer info by ID + */ + getBufferInfo(bufferId: number): BufferInfo | null; + /** + * Get primary cursor info for active buffer + */ + getPrimaryCursor(): unknown; + /** + * Get all cursors for active buffer + */ + getAllCursors(): unknown; + /** + * Get all cursor positions as byte offsets + */ + getAllCursorPositions(): unknown; + /** + * Get viewport info for active buffer + */ + getViewport(): ViewportInfo | null; + /** + * Get the line number (0-indexed) of the primary cursor + */ + getCursorLine(): number; + /** + * Get the byte offset of the start of a line (0-indexed line number) + * Returns null if the line number is out of range + */ + getLineStartPosition(line: number): Promise; + /** + * Find buffer by file path, returns buffer ID or 0 if not found + */ + findBufferByPath(path: string): number; + /** + * Get diff between buffer content and last saved version + */ + getBufferSavedDiff(bufferId: number): BufferSavedDiff | null; + /** + * Insert text at a position in a buffer + */ + insertText(bufferId: number, position: number, text: string): boolean; + /** + * Delete a range from a buffer + */ + deleteRange(bufferId: number, start: number, end: number): boolean; + /** + * Insert text at cursor position in active buffer + */ + insertAtCursor(text: string): boolean; + /** + * Open a file, optionally at a specific line/column + */ + openFile(path: string, line: number | null, column: number | null): boolean; + /** + * Open a file in a specific split + */ + openFileInSplit( + splitId: number, + path: string, + line: number, + column: number, + ): boolean; + /** + * Show a buffer in the current split + */ + showBuffer(bufferId: number): boolean; + /** + * Close a buffer + */ + closeBuffer(bufferId: number): boolean; + /** + * Subscribe to an editor event + */ + on(eventName: string, handlerName: string): void; + /** + * Unsubscribe from an event + */ + off(eventName: string, handlerName: string): void; + /** + * Get an environment variable + */ + getEnv(name: string): string | null; + /** + * Get current working directory + */ + getCwd(): string; + /** + * Join path components (variadic - accepts multiple string arguments) + * Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join) + */ + pathJoin(...parts: string[]): string; + /** + * Get directory name from path + */ + pathDirname(path: string): string; + /** + * Get file name from path + */ + pathBasename(path: string): string; + /** + * Get file extension + */ + pathExtname(path: string): string; + /** + * Check if path is absolute + */ + pathIsAbsolute(path: string): boolean; + /** + * Check if file exists + */ + fileExists(path: string): boolean; + /** + * Read file contents + */ + readFile(path: string): string | null; + /** + * Write file contents + */ + writeFile(path: string, content: string): boolean; + /** + * Read directory contents (returns array of {name, is_file, is_dir}) + */ + readDir(path: string): DirEntry[]; + /** + * Get current config as JS object + */ + getConfig(): unknown; + /** + * Get user config as JS object + */ + getUserConfig(): unknown; + /** + * Reload configuration from file + */ + reloadConfig(): void; + /** + * Reload theme registry from disk + * Call this after installing theme packages or saving new themes + */ + reloadThemes(): void; + /** + * Register a TextMate grammar file for a language + * The grammar will be pending until reload_grammars() is called + */ + registerGrammar( + language: string, + grammarPath: string, + extensions: string[], + ): boolean; + /** + * Register language configuration (comment prefix, indentation, formatter) + */ + registerLanguageConfig(language: string, config: LanguagePackConfig): boolean; + /** + * Register an LSP server for a language + */ + registerLspServer(language: string, config: LspServerPackConfig): boolean; + /** + * Reload the grammar registry to apply registered grammars + * Call this after registering one or more grammars + */ + reloadGrammars(): void; + /** + * Get config directory path + */ + getConfigDir(): string; + /** + * Get themes directory path + */ + getThemesDir(): string; + /** + * Apply a theme by name + */ + applyTheme(themeName: string): boolean; + /** + * Get theme schema as JS object + */ + getThemeSchema(): unknown; + /** + * Get list of builtin themes as JS object + */ + getBuiltinThemes(): unknown; + /** + * Delete a custom theme (alias for deleteThemeSync) + */ + deleteTheme(name: string): boolean; + /** + * Get file stat information + */ + fileStat(path: string): unknown; + /** + * Check if a background process is still running + */ + isProcessRunning(ProcessId: number): boolean; + /** + * Kill a process by ID (alias for killBackgroundProcess) + */ + killProcess(processId: number): boolean; + /** + * Translate a key for a specific plugin + */ + pluginTranslate( + pluginName: string, + key: string, + args?: Record, + ): string; + /** + * Create a composite buffer (async) + * + * Uses typed CreateCompositeBufferOptions - serde validates field names at runtime + * via `deny_unknown_fields` attribute + */ + createCompositeBuffer(opts: CreateCompositeBufferOptions): Promise; + /** + * Update alignment hunks for a composite buffer + * + * Uses typed Vec - serde validates field names at runtime + */ + updateCompositeAlignment(bufferId: number, hunks: CompositeHunk[]): boolean; + /** + * Close a composite buffer + */ + closeCompositeBuffer(bufferId: number): boolean; + /** + * Request syntax highlights for a buffer range (async) + */ + getHighlights( + bufferId: number, + start: number, + end: number, + ): Promise; + /** + * Add an overlay with styling options + * + * Colors can be specified as RGB arrays `[r, g, b]` or theme key strings. + * Theme keys are resolved at render time, so overlays update with theme changes. + * + * Theme key examples: "ui.status_bar_fg", "editor.selection_bg", "syntax.keyword" + * + * Example usage in TypeScript: + * ```typescript + * editor.addOverlay(bufferId, "my-namespace", 0, 10, { + * fg: "syntax.keyword", // theme key + * bg: [40, 40, 50], // RGB array + * bold: true, + * }); + * ``` + */ + addOverlay( + bufferId: number, + namespace: string, + start: number, + end: number, + options: Record, + ): boolean; + /** + * Clear all overlays in a namespace + */ + clearNamespace(bufferId: number, namespace: string): boolean; + /** + * Clear all overlays from a buffer + */ + clearAllOverlays(bufferId: number): boolean; + /** + * Clear all overlays that overlap with a byte range + */ + clearOverlaysInRange(bufferId: number, start: number, end: number): boolean; + /** + * Remove an overlay by its handle + */ + removeOverlay(bufferId: number, handle: string): boolean; + /** + * Submit a view transform for a buffer/split + * + * Note: tokens should be ViewTokenWire[], layoutHints should be LayoutHints + * These use manual parsing due to complex enum handling + */ + submitViewTransform( + bufferId: number, + splitId: number | null, + start: number, + end: number, + tokens: Record[], + LayoutHints?: Record, + ): boolean; + /** + * Clear view transform for a buffer/split + */ + clearViewTransform(bufferId: number, splitId: number | null): boolean; + /** + * Set file explorer decorations for a namespace + */ + setFileExplorerDecorations( + namespace: string, + decorations: Record[], + ): boolean; + /** + * Clear file explorer decorations for a namespace + */ + clearFileExplorerDecorations(namespace: string): boolean; + /** + * Add virtual text (inline text that doesn't exist in the buffer) + */ + addVirtualText( + bufferId: number, + virtualTextId: string, + position: number, + text: string, + r: number, + g: number, + b: number, + before: boolean, + useBg: boolean, + ): boolean; + /** + * Remove a virtual text by ID + */ + removeVirtualText(bufferId: number, virtualTextId: string): boolean; + /** + * Remove virtual texts whose ID starts with the given prefix + */ + removeVirtualTextsByPrefix(bufferId: number, prefix: string): boolean; + /** + * Clear all virtual texts from a buffer + */ + clearVirtualTexts(bufferId: number): boolean; + /** + * Clear all virtual texts in a namespace + */ + clearVirtualTextNamespace(bufferId: number, namespace: string): boolean; + /** + * Add a virtual line (full line above/below a position) + */ + addVirtualLine( + bufferId: number, + position: number, + text: string, + fgR: number, + fgG: number, + fgB: number, + bgR: number, + bgG: number, + bgB: number, + above: boolean, + namespace: string, + priority: number, + ): boolean; + /** + * Show a prompt and wait for user input (async) + * Returns the user input or null if cancelled + */ + prompt(label: string, initialValue: string): Promise; + /** + * Start an interactive prompt + */ + startPrompt(label: string, promptType: string): boolean; + /** + * Start a prompt with initial value + */ + startPromptWithInitial( + label: string, + promptType: string, + initialValue: string, + ): boolean; + /** + * Set suggestions for the current prompt + * + * Uses typed Vec - serde validates field names at runtime + */ + setPromptSuggestions(suggestions: Suggestion[]): boolean; + /** + * Define a buffer mode (takes bindings as array of [key, command] pairs) + */ + defineMode( + name: string, + parent: string | null, + bindingsArr: string[][], + readOnly?: boolean, + ): boolean; + /** + * Set the global editor mode + */ + setEditorMode(mode: string | null): boolean; + /** + * Get the current editor mode + */ + getEditorMode(): string | null; + /** + * Close a split + */ + closeSplit(splitId: number): boolean; + /** + * Set the buffer displayed in a split + */ + setSplitBuffer(splitId: number, bufferId: number): boolean; + /** + * Focus a specific split + */ + focusSplit(splitId: number): boolean; + /** + * Set scroll position of a split + */ + setSplitScroll(splitId: number, topByte: number): boolean; + /** + * Set the ratio of a split (0.0 to 1.0, 0.5 = equal) + */ + setSplitRatio(splitId: number, ratio: number): boolean; + /** + * Distribute all splits evenly + */ + distributeSplitsEvenly(): boolean; + /** + * Set cursor position in a buffer + */ + setBufferCursor(bufferId: number, position: number): boolean; + /** + * Set a line indicator in the gutter + */ + setLineIndicator( + bufferId: number, + line: number, + namespace: string, + symbol: string, + r: number, + g: number, + b: number, + priority: number, + ): boolean; + /** + * Clear line indicators in a namespace + */ + clearLineIndicators(bufferId: number, namespace: string): boolean; + /** + * Enable or disable line numbers for a buffer + */ + setLineNumbers(bufferId: number, enabled: boolean): boolean; + /** + * Create a scroll sync group for anchor-based synchronized scrolling + */ + createScrollSyncGroup( + groupId: number, + leftSplit: number, + rightSplit: number, + ): boolean; + /** + * Set sync anchors for a scroll sync group + */ + setScrollSyncAnchors(groupId: number, anchors: number[][]): boolean; + /** + * Remove a scroll sync group + */ + removeScrollSyncGroup(groupId: number): boolean; + /** + * Execute multiple actions in sequence + * + * Takes typed ActionSpec array - serde validates field names at runtime + */ + executeActions(actions: ActionSpec[]): boolean; + /** + * Show an action popup + * + * Takes a typed ActionPopupOptions struct - serde validates field names at runtime + */ + showActionPopup(opts: ActionPopupOptions): boolean; + /** + * Disable LSP for a specific language + */ + disableLspForLanguage(language: string): boolean; + /** + * Set the workspace root URI for a specific language's LSP server + * This allows plugins to specify project roots (e.g., directory containing .csproj) + */ + setLspRootUri(language: string, uri: string): boolean; + /** + * Get all diagnostics from LSP + */ + getAllDiagnostics(): JsDiagnostic[]; + /** + * Get registered event handlers for an event + */ + getHandlers(eventName: string): string[]; + /** + * Create a virtual buffer in current split (async, returns buffer and split IDs) + */ + createVirtualBuffer( + opts: CreateVirtualBufferOptions, + ): Promise; + /** + * Create a virtual buffer in a new split (async, returns buffer and split IDs) + */ + createVirtualBufferInSplit( + opts: CreateVirtualBufferInSplitOptions, + ): Promise; + /** + * Create a virtual buffer in an existing split (async, returns buffer and split IDs) + */ + createVirtualBufferInExistingSplit( + opts: CreateVirtualBufferInExistingSplitOptions, + ): Promise; + /** + * Set virtual buffer content (takes array of entry objects) + * + * Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support + */ + setVirtualBufferContent( + bufferId: number, + entriesArr: Record[], + ): boolean; + /** + * Get text properties at cursor position (returns JS array) + */ + getTextPropertiesAtCursor(bufferId: number): TextPropertiesAtCursor; + /** + * Spawn a process (async, returns request_id) + */ + spawnProcess( + command: string, + args: string[], + cwd?: string, + ): ProcessHandle; + /** + * Wait for a process to complete and get its result (async) + */ + spawnProcessWait(processId: number): Promise; + /** + * Get buffer text range (async, returns request_id) + */ + getBufferText(bufferId: number, start: number, end: number): Promise; + /** + * Delay/sleep (async, returns request_id) + */ + delay(durationMs: number): Promise; + /** + * Send LSP request (async, returns request_id) + */ + sendLspRequest( + language: string, + method: string, + params: Record | null, + ): Promise; + /** + * Spawn a background process (async, returns request_id which is also process_id) + */ + spawnBackgroundProcess( + command: string, + args: string[], + cwd?: string, + ): ProcessHandle; + /** + * Kill a background process + */ + killBackgroundProcess(processId: number): boolean; + /** + * Force refresh of line display + */ + refreshLines(bufferId: number): boolean; + /** + * Get the current locale + */ + getCurrentLocale(): string; + /** + * Load a plugin from a file path (async) + */ + loadPlugin(path: string): Promise; + /** + * Unload a plugin by name (async) + */ + unloadPlugin(name: string): Promise; + /** + * Reload a plugin by name (async) + */ + reloadPlugin(name: string): Promise; + /** + * List all loaded plugins (async) + * Returns array of { name: string, path: string, enabled: boolean } + */ + listPlugins(): Promise< + Array<{ + name: string; + path: string; + enabled: boolean; + }> + >; } diff --git a/crates/fresh-editor/plugins/lib/index.ts b/crates/fresh-editor/plugins/lib/index.ts index a1e10eaf8..73087197d 100644 --- a/crates/fresh-editor/plugins/lib/index.ts +++ b/crates/fresh-editor/plugins/lib/index.ts @@ -34,13 +34,13 @@ // Types export type { - RGB, + FileExplorerDecoration, + HighlightPattern, Location, + NavigationOptions, PanelOptions, PanelState, - NavigationOptions, - HighlightPattern, - FileExplorerDecoration, + RGB, } from "./types.ts"; // Panel Management @@ -51,40 +51,56 @@ export { NavigationController } from "./navigation-controller.ts"; // Buffer Creation export { createVirtualBufferFactory } from "./virtual-buffer-factory.ts"; -export type { VirtualBufferOptions, SplitBufferOptions } from "./virtual-buffer-factory.ts"; +export type { + SplitBufferOptions, + VirtualBufferOptions, +} from "./virtual-buffer-factory.ts"; // Finder Abstraction -export { Finder, defaultFuzzyFilter, parseGrepLine, parseGrepOutput, getRelativePath, createLiveProvider } from "./finder.ts"; +export { + createLiveProvider, + defaultFuzzyFilter, + Finder, + getRelativePath, + parseGrepLine, + parseGrepOutput, +} from "./finder.ts"; export type { DisplayEntry, - SearchSource, FilterSource, - PreviewConfig, FinderConfig, - PromptOptions, - PanelOptions as FinderPanelOptions, FinderProvider, LivePanelOptions, + PanelOptions as FinderPanelOptions, + PreviewConfig, + PromptOptions, + SearchSource, } from "./finder.ts"; // UI Controls Library export { - FocusState, ButtonControl, - ToggleButton, - ListControl, - GroupedListControl, + FilterBar, FocusManager, - TextInputControl, - Separator, + FocusState, + GroupedListControl, + HelpBar, Label, + ListControl, + Separator, + SplitView, + TextInputControl, + ToggleButton, } from "./controls.ts"; export type { - StyleRange, ControlOutput, + FilterOption, ItemRenderer, + KeyBinding, ListGroup, + PanelLine, + StyleRange, } from "./controls.ts"; // Virtual Buffer Builder -export { VirtualBufferBuilder, createBuilder } from "./vbuffer.ts"; +export { createBuilder, VirtualBufferBuilder } from "./vbuffer.ts"; diff --git a/crates/fresh-editor/plugins/lib/navigation-controller.ts b/crates/fresh-editor/plugins/lib/navigation-controller.ts index 6c0dbd952..242421dc8 100644 --- a/crates/fresh-editor/plugins/lib/navigation-controller.ts +++ b/crates/fresh-editor/plugins/lib/navigation-controller.ts @@ -33,7 +33,10 @@ export class NavigationController { private currentIndex: number = 0; private options: NavigationOptions; - constructor(private readonly editor: EditorAPI, options: NavigationOptions = {}) { + constructor( + private readonly editor: EditorAPI, + options: NavigationOptions = {}, + ) { this.options = { itemLabel: "Item", wrap: false, @@ -53,7 +56,10 @@ export class NavigationController { this.currentIndex = 0; } else { // Clamp to valid range - this.currentIndex = Math.min(this.currentIndex, Math.max(0, items.length - 1)); + this.currentIndex = Math.min( + this.currentIndex, + Math.max(0, items.length - 1), + ); } } @@ -113,7 +119,10 @@ export class NavigationController { if (this.options.wrap) { this.currentIndex = (this.currentIndex + 1) % this.items.length; } else { - this.currentIndex = Math.min(this.currentIndex + 1, this.items.length - 1); + this.currentIndex = Math.min( + this.currentIndex + 1, + this.items.length - 1, + ); } this.notifyChange(); } @@ -125,7 +134,8 @@ export class NavigationController { if (this.items.length === 0) return; if (this.options.wrap) { - this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length; + this.currentIndex = (this.currentIndex - 1 + this.items.length) % + this.items.length; } else { this.currentIndex = Math.max(this.currentIndex - 1, 0); } diff --git a/crates/fresh-editor/plugins/lib/panel-manager.ts b/crates/fresh-editor/plugins/lib/panel-manager.ts index ab4dcdde4..cd40fbdc1 100644 --- a/crates/fresh-editor/plugins/lib/panel-manager.ts +++ b/crates/fresh-editor/plugins/lib/panel-manager.ts @@ -47,7 +47,7 @@ export class PanelManager { constructor( private readonly editor: EditorAPI, private readonly panelName: string, - private readonly modeName: string + private readonly modeName: string, ) {} /** @@ -94,7 +94,12 @@ export class PanelManager { * @returns The buffer ID of the panel */ async open(options: PanelOptions): Promise { - const { entries, ratio = 0.3, showLineNumbers = false, editingDisabled = true } = options; + const { + entries, + ratio = 0.3, + showLineNumbers = false, + editingDisabled = true, + } = options; if (this.state.isOpen && this.state.bufferId !== null) { // Panel already open - just update content @@ -199,7 +204,11 @@ export class PanelManager { * @param line - Line number to jump to (1-indexed) * @param column - Column number to jump to (1-indexed) */ - async openInSource(filePath: string, line: number, column: number): Promise { + async openInSource( + filePath: string, + line: number, + column: number, + ): Promise { if (this.state.sourceSplitId === null) { return; } diff --git a/crates/fresh-editor/plugins/lib/search-utils.ts b/crates/fresh-editor/plugins/lib/search-utils.ts index fe2f5cbdc..6f6f02afb 100644 --- a/crates/fresh-editor/plugins/lib/search-utils.ts +++ b/crates/fresh-editor/plugins/lib/search-utils.ts @@ -37,7 +37,12 @@ export interface DebouncedSearchOptions { // Editor interface (subset of what we need) interface EditorApi { readFile(path: string): Promise; - defineMode(name: string, parent: string, bindings: [string, string][], readOnly: boolean): void; + defineMode( + name: string, + parent: string, + bindings: [string, string][], + readOnly: boolean, + ): void; createVirtualBufferInSplit(options: { name: string; mode: string; @@ -131,7 +136,10 @@ export class SearchPreview { if (this.bufferId === null) { // Create preview mode if not exists - this.editor.defineMode(this.modeName, "special", [["q", "close_buffer"]], true); + this.editor.defineMode(this.modeName, "special", [[ + "q", + "close_buffer", + ]], true); // Create preview in a split on the right const result = await this.editor.createVirtualBufferInSplit({ @@ -217,7 +225,7 @@ export class DebouncedSearch { async search( query: string, executor: () => ProcessHandle, - onResults: (result: SpawnResult) => void + onResults: (result: SpawnResult) => void, ): Promise { const thisVersion = ++this.searchVersion; @@ -320,16 +328,15 @@ export function parseGrepLine(line: string): SearchMatch | null { */ export function matchesToSuggestions( matches: SearchMatch[], - maxResults: number = 100 + maxResults: number = 100, ): PromptSuggestion[] { const suggestions: PromptSuggestion[] = []; for (let i = 0; i < Math.min(matches.length, maxResults); i++) { const match = matches[i]; - const displayContent = - match.content.length > 60 - ? match.content.substring(0, 57) + "..." - : match.content; + const displayContent = match.content.length > 60 + ? match.content.substring(0, 57) + "..." + : match.content; suggestions.push({ text: `${match.file}:${match.line}`, diff --git a/crates/fresh-editor/plugins/lib/vbuffer.ts b/crates/fresh-editor/plugins/lib/vbuffer.ts index 390bbae97..74eb32d6f 100644 --- a/crates/fresh-editor/plugins/lib/vbuffer.ts +++ b/crates/fresh-editor/plugins/lib/vbuffer.ts @@ -28,7 +28,7 @@ * ``` */ -import type { StyleRange, ControlOutput } from "./controls.ts"; +import type { ControlOutput, StyleRange } from "./controls.ts"; import type { RGB } from "./types.ts"; const editor = getEditor(); @@ -54,7 +54,7 @@ export class VirtualBufferBuilder { /** Buffer ID to write to */ private bufferId: number, /** Namespace for overlays (used in clearNamespace) */ - private namespace: string = "ui" + private namespace: string = "ui", ) {} /** @@ -148,7 +148,11 @@ export class VirtualBufferBuilder { * @param content - Content control output * @param labelFg - Label foreground color */ - labeledRow(label: string, content: ControlOutput, labelFg?: string | RGB): this { + labeledRow( + label: string, + content: ControlOutput, + labelFg?: string | RGB, + ): this { const labelOutput: ControlOutput = { text: label, styles: labelFg ? [{ start: 0, end: label.length, fg: labelFg }] : [], @@ -179,7 +183,12 @@ export class VirtualBufferBuilder { * @param bg - Background color * @param bold - Bold text */ - styled(content: string, fg?: string | RGB, bg?: string | RGB, bold?: boolean): this { + styled( + content: string, + fg?: string | RGB, + bg?: string | RGB, + bold?: boolean, + ): this { const styles: StyleRange[] = []; if (fg || bg || bold) { styles.push({ start: 0, end: content.length, fg, bg, bold }); @@ -213,7 +222,7 @@ export class VirtualBufferBuilder { left: ControlOutput, right: ControlOutput, leftWidth: number, - divider: string = " | " + divider: string = " | ", ): this { // Pad/truncate left column let leftText = left.text; @@ -225,7 +234,7 @@ export class VirtualBufferBuilder { const paddedLeft: ControlOutput = { text: leftText, - styles: left.styles.map(s => ({ + styles: left.styles.map((s) => ({ ...s, end: Math.min(s.end, leftText.length), })), @@ -258,7 +267,10 @@ export class VirtualBufferBuilder { * @param items - Items to iterate * @param fn - Function to add content for each item */ - forEach(items: T[], fn: (builder: this, item: T, index: number) => void): this { + forEach( + items: T[], + fn: (builder: this, item: T, index: number) => void, + ): this { items.forEach((item, index) => fn(this, item, index)); return this; } @@ -300,7 +312,10 @@ export class VirtualBufferBuilder { } // Convert to TextPropertyEntry format - const textEntries: TextPropertyEntry[] = [{ text: fullText, properties: {} }]; + const textEntries: TextPropertyEntry[] = [{ + text: fullText, + properties: {}, + }]; editor.setVirtualBufferContent(this.bufferId, textEntries); // Clear existing overlays and apply new ones @@ -319,7 +334,13 @@ export class VirtualBufferBuilder { if (style.underline) options.underline = true; if (Object.keys(options).length > 0) { - editor.addOverlay(this.bufferId, this.namespace, byteStart, byteEnd, options); + editor.addOverlay( + this.bufferId, + this.namespace, + byteStart, + byteEnd, + options, + ); } } } @@ -328,7 +349,7 @@ export class VirtualBufferBuilder { * Get the combined text without building (useful for debugging) */ getText(): string { - return this.entries.map(e => e.text).join(""); + return this.entries.map((e) => e.text).join(""); } /** @@ -369,6 +390,9 @@ export class VirtualBufferBuilder { * @param bufferId - Buffer ID to write to * @param namespace - Namespace for overlays */ -export function createBuilder(bufferId: number, namespace: string = "ui"): VirtualBufferBuilder { +export function createBuilder( + bufferId: number, + namespace: string = "ui", +): VirtualBufferBuilder { return new VirtualBufferBuilder(bufferId, namespace); } diff --git a/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts b/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts index 9ebf341ad..07374b138 100644 --- a/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts +++ b/crates/fresh-editor/plugins/lib/virtual-buffer-factory.ts @@ -73,7 +73,10 @@ export function createVirtualBufferFactory(editor: EditorAPI) { /** * Create a virtual buffer in an existing split */ - async createInSplit(splitId: number, options: VirtualBufferOptions): Promise { + async createInSplit( + splitId: number, + options: VirtualBufferOptions, + ): Promise { const { name, mode, diff --git a/crates/fresh-editor/plugins/pkg.ts b/crates/fresh-editor/plugins/pkg.ts index 2d2f83252..b14038c52 100644 --- a/crates/fresh-editor/plugins/pkg.ts +++ b/crates/fresh-editor/plugins/pkg.ts @@ -22,12 +22,19 @@ import { Finder } from "./lib/finder.ts"; import { ButtonControl, + FilterBar, FocusState, GroupedListControl, - TextInputControl, + HelpBar, + SplitView, VirtualBufferBuilder, } from "./lib/index.ts"; -import type { ListGroup } from "./lib/index.ts"; +import type { + FilterOption, + KeyBinding, + ListGroup, + PanelLine, +} from "./lib/index.ts"; const editor = getEditor(); @@ -38,7 +45,11 @@ const editor = getEditor(); const CONFIG_DIR = editor.getConfigDir(); const PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "plugins", "packages"); const THEMES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "themes", "packages"); -const LANGUAGES_PACKAGES_DIR = editor.pathJoin(CONFIG_DIR, "languages", "packages"); +const LANGUAGES_PACKAGES_DIR = editor.pathJoin( + CONFIG_DIR, + "languages", + "packages", +); const INDEX_DIR = editor.pathJoin(PACKAGES_DIR, ".index"); const CACHE_DIR = editor.pathJoin(PACKAGES_DIR, ".cache"); const LOCKFILE_PATH = editor.pathJoin(CONFIG_DIR, "fresh.lock"); @@ -192,14 +203,18 @@ function hashString(str: string): string { * Run a git command without prompting for credentials. * Uses git config options to prevent interactive prompts (cross-platform). */ -async function gitCommand(args: string[]): Promise<{ exit_code: number; stdout: string; stderr: string }> { +async function gitCommand( + args: string[], +): Promise<{ exit_code: number; stdout: string; stderr: string }> { // Use git config options to disable credential prompts (works on Windows and Unix) // -c credential.helper= disables credential helper // -c core.askPass= disables askpass program const gitArgs = [ - "-c", "credential.helper=", - "-c", "core.askPass=", - ...args + "-c", + "credential.helper=", + "-c", + "core.askPass=", + ...args, ]; const result = await editor.spawnProcess("git", gitArgs); return result; @@ -315,22 +330,36 @@ async function syncRegistry(): Promise { if (editor.fileExists(indexPath)) { // Update existing editor.setStatus(`Updating registry: ${source}...`); - const result = await gitCommand(["-C", `${indexPath}`, "pull", "--ff-only"]); + const result = await gitCommand([ + "-C", + `${indexPath}`, + "pull", + "--ff-only", + ]); if (result.exit_code === 0) { synced++; } else { const errorMsg = result.stderr.includes("Could not resolve host") ? "Network error" - : result.stderr.includes("Authentication") || result.stderr.includes("403") + : result.stderr.includes("Authentication") || + result.stderr.includes("403") ? "Authentication failed (check if repo is public)" : result.stderr.split("\n")[0] || "Unknown error"; errors.push(`${source}: ${errorMsg}`); - editor.warn(`[pkg] Failed to update registry ${source}: ${result.stderr}`); + editor.warn( + `[pkg] Failed to update registry ${source}: ${result.stderr}`, + ); } } else { // Clone new editor.setStatus(`Cloning registry: ${source}...`); - const result = await gitCommand(["clone", "--depth", "1", `${source}`, `${indexPath}`]); + const result = await gitCommand([ + "clone", + "--depth", + "1", + `${source}`, + `${indexPath}`, + ]); if (result.exit_code === 0) { synced++; } else { @@ -338,11 +367,14 @@ async function syncRegistry(): Promise { ? "Network error" : result.stderr.includes("not found") || result.stderr.includes("404") ? "Repository not found" - : result.stderr.includes("Authentication") || result.stderr.includes("403") + : result.stderr.includes("Authentication") || + result.stderr.includes("403") ? "Authentication failed (check if repo is public)" : result.stderr.split("\n")[0] || "Unknown error"; errors.push(`${source}: ${errorMsg}`); - editor.warn(`[pkg] Failed to clone registry ${source}: ${result.stderr}`); + editor.warn( + `[pkg] Failed to clone registry ${source}: ${result.stderr}`, + ); } } } @@ -353,7 +385,11 @@ async function syncRegistry(): Promise { } if (errors.length > 0) { - editor.setStatus(`Registry: ${synced}/${sources.length} synced. Errors: ${errors.join("; ")}`); + editor.setStatus( + `Registry: ${synced}/${sources.length} synced. Errors: ${ + errors.join("; ") + }`, + ); } else { editor.setStatus(`Registry synced (${synced}/${sources.length} sources)`); } @@ -369,31 +405,44 @@ function loadRegistry(type: "plugins" | "themes" | "languages"): RegistryData { const merged: RegistryData = { schema_version: 1, updated: new Date().toISOString(), - packages: {} + packages: {}, }; for (const source of sources) { // Try git index first - const indexPath = editor.pathJoin(INDEX_DIR, hashString(source), `${type}.json`); + const indexPath = editor.pathJoin( + INDEX_DIR, + hashString(source), + `${type}.json`, + ); editor.debug(`[pkg] checking index path: ${indexPath}`); let data = readJsonFile(indexPath); // Fall back to cache if index not available if (!data?.packages) { - const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_${type}.json`); + const cachePath = editor.pathJoin( + CACHE_DIR, + `${hashString(source)}_${type}.json`, + ); data = readJsonFile(cachePath); if (data?.packages) { editor.debug(`[pkg] using cached data for ${type}`); } } - editor.debug(`[pkg] data loaded: ${data ? 'yes' : 'no'}, packages: ${data?.packages ? Object.keys(data.packages).length : 0}`); + editor.debug( + `[pkg] data loaded: ${data ? "yes" : "no"}, packages: ${ + data?.packages ? Object.keys(data.packages).length : 0 + }`, + ); if (data?.packages) { Object.assign(merged.packages, data.packages); } } - editor.debug(`[pkg] total merged packages: ${Object.keys(merged.packages).length}`); + editor.debug( + `[pkg] total merged packages: ${Object.keys(merged.packages).length}`, + ); return merged; } @@ -408,7 +457,10 @@ async function cacheRegistry(): Promise { const sourceHash = hashString(source); for (const type of ["plugins", "themes", "languages"] as const) { const indexPath = editor.pathJoin(INDEX_DIR, sourceHash, `${type}.json`); - const cachePath = editor.pathJoin(CACHE_DIR, `${sourceHash}_${type}.json`); + const cachePath = editor.pathJoin( + CACHE_DIR, + `${sourceHash}_${type}.json`, + ); const data = readJsonFile(indexPath); if (data?.packages && Object.keys(data.packages).length > 0) { @@ -430,7 +482,10 @@ function isRegistrySynced(): boolean { return true; } // Check cache - const cachePath = editor.pathJoin(CACHE_DIR, `${hashString(source)}_plugins.json`); + const cachePath = editor.pathJoin( + CACHE_DIR, + `${hashString(source)}_plugins.json`, + ); if (editor.fileExists(cachePath)) { return true; } @@ -445,10 +500,14 @@ function isRegistrySynced(): boolean { /** * Get list of installed packages */ -function getInstalledPackages(type: "plugin" | "theme" | "language"): InstalledPackage[] { - const packagesDir = type === "plugin" ? PACKAGES_DIR - : type === "theme" ? THEMES_PACKAGES_DIR - : LANGUAGES_PACKAGES_DIR; +function getInstalledPackages( + type: "plugin" | "theme" | "language", +): InstalledPackage[] { + const packagesDir = type === "plugin" + ? PACKAGES_DIR + : type === "theme" + ? THEMES_PACKAGES_DIR + : LANGUAGES_PACKAGES_DIR; const packages: InstalledPackage[] = []; if (!editor.fileExists(packagesDir)) { @@ -482,7 +541,7 @@ function getInstalledPackages(type: "plugin" | "theme" | "language"): InstalledP type, source, version: manifest?.version || "unknown", - manifest + manifest, }); } } @@ -511,14 +570,17 @@ interface ValidationResult { * 2. package.json has required fields (name, type) * 3. Entry file exists (for plugins) */ -function validatePackage(packageDir: string, packageName: string): ValidationResult { +function validatePackage( + packageDir: string, + packageName: string, +): ValidationResult { const manifestPath = editor.pathJoin(packageDir, "package.json"); // Check package.json exists if (!editor.fileExists(manifestPath)) { return { valid: false, - error: `Missing package.json - expected at ${manifestPath}` + error: `Missing package.json - expected at ${manifestPath}`, }; } @@ -527,7 +589,7 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes if (!manifest) { return { valid: false, - error: "Invalid package.json - could not parse JSON" + error: "Invalid package.json - could not parse JSON", }; } @@ -535,21 +597,26 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes if (!manifest.name) { return { valid: false, - error: "Invalid package.json - missing 'name' field" + error: "Invalid package.json - missing 'name' field", }; } if (!manifest.type) { return { valid: false, - error: "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', or 'language')" + error: + "Invalid package.json - missing 'type' field (should be 'plugin', 'theme', or 'language')", }; } - if (manifest.type !== "plugin" && manifest.type !== "theme" && manifest.type !== "language") { + if ( + manifest.type !== "plugin" && manifest.type !== "theme" && + manifest.type !== "language" + ) { return { valid: false, - error: `Invalid package.json - 'type' must be 'plugin', 'theme', or 'language', got '${manifest.type}'` + error: + `Invalid package.json - 'type' must be 'plugin', 'theme', or 'language', got '${manifest.type}'`, }; } @@ -567,7 +634,8 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes return { valid: false, - error: `Missing entry file '${entryFile}' - check fresh.entry in package.json` + error: + `Missing entry file '${entryFile}' - check fresh.entry in package.json`, }; } @@ -576,20 +644,27 @@ function validatePackage(packageDir: string, packageName: string): ValidationRes // For language packs, validate at least one component is defined if (manifest.type === "language") { - if (!manifest.fresh?.grammar && !manifest.fresh?.language && !manifest.fresh?.lsp) { + if ( + !manifest.fresh?.grammar && !manifest.fresh?.language && + !manifest.fresh?.lsp + ) { return { valid: false, - error: "Language package must define at least one of: grammar, language, or lsp" + error: + "Language package must define at least one of: grammar, language, or lsp", }; } // Validate grammar file exists if specified if (manifest.fresh?.grammar?.file) { - const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file); + const grammarPath = editor.pathJoin( + packageDir, + manifest.fresh.grammar.file, + ); if (!editor.fileExists(grammarPath)) { return { valid: false, - error: `Grammar file not found: ${manifest.fresh.grammar.file}` + error: `Grammar file not found: ${manifest.fresh.grammar.file}`, }; } } @@ -613,13 +688,15 @@ async function installPackage( url: string, name?: string, type: "plugin" | "theme" | "language" = "plugin", - version?: string + version?: string, ): Promise { const parsed = parsePackageUrl(url); const packageName = name || parsed.name; - const packagesDir = type === "plugin" ? PACKAGES_DIR - : type === "theme" ? THEMES_PACKAGES_DIR - : LANGUAGES_PACKAGES_DIR; + const packagesDir = type === "plugin" + ? PACKAGES_DIR + : type === "theme" + ? THEMES_PACKAGES_DIR + : LANGUAGES_PACKAGES_DIR; const targetDir = editor.pathJoin(packagesDir, packageName); if (editor.fileExists(targetDir)) { @@ -636,7 +713,12 @@ async function installPackage( return await installFromMonorepo(parsed, packageName, targetDir, version); } else { // Standard installation: clone directly - return await installFromRepo(parsed.repoUrl, packageName, targetDir, version); + return await installFromRepo( + parsed.repoUrl, + packageName, + targetDir, + version, + ); } } @@ -647,7 +729,7 @@ async function installFromRepo( repoUrl: string, packageName: string, targetDir: string, - version?: string + version?: string, ): Promise { // Clone the repository const cloneArgs = ["clone"]; @@ -659,11 +741,13 @@ async function installFromRepo( const result = await gitCommand(cloneArgs); if (result.exit_code !== 0) { - const errorMsg = result.stderr.includes("not found") || result.stderr.includes("404") - ? "Repository not found" - : result.stderr.includes("Authentication") || result.stderr.includes("403") - ? "Access denied (repository may be private)" - : result.stderr.split("\n")[0] || "Clone failed"; + const errorMsg = + result.stderr.includes("not found") || result.stderr.includes("404") + ? "Repository not found" + : result.stderr.includes("Authentication") || + result.stderr.includes("403") + ? "Access denied (repository may be private)" + : result.stderr.split("\n")[0] || "Clone failed"; editor.setStatus(`Failed to install ${packageName}: ${errorMsg}`); return false; } @@ -672,7 +756,9 @@ async function installFromRepo( if (version && version !== "latest") { const checkoutResult = await checkoutVersion(targetDir, version); if (!checkoutResult) { - editor.setStatus(`Installed ${packageName} but failed to checkout version ${version}`); + editor.setStatus( + `Installed ${packageName} but failed to checkout version ${version}`, + ); } } @@ -691,15 +777,29 @@ async function installFromRepo( // Dynamically load plugins, reload themes, or load language packs if (manifest?.type === "plugin" && validation.entryPath) { await editor.loadPlugin(validation.entryPath); - editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed and activated ${packageName}${ + manifest ? ` v${manifest.version}` : "" + }`, + ); } else if (manifest?.type === "theme") { editor.reloadThemes(); - editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed theme ${packageName}${ + manifest ? ` v${manifest.version}` : "" + }`, + ); } else if (manifest?.type === "language") { await loadLanguagePack(targetDir, manifest); - editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed language pack ${packageName}${ + manifest ? ` v${manifest.version}` : "" + }`, + ); } else { - editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`, + ); } return true; } @@ -717,7 +817,7 @@ async function installFromMonorepo( parsed: ParsedPackageUrl, packageName: string, targetDir: string, - version?: string + version?: string, ): Promise { const tempDir = `/tmp/fresh-pkg-${hashString(parsed.repoUrl)}-${Date.now()}`; @@ -732,9 +832,11 @@ async function installFromMonorepo( const cloneResult = await gitCommand(cloneArgs); if (cloneResult.exit_code !== 0) { - const errorMsg = cloneResult.stderr.includes("not found") || cloneResult.stderr.includes("404") + const errorMsg = cloneResult.stderr.includes("not found") || + cloneResult.stderr.includes("404") ? "Repository not found" - : cloneResult.stderr.includes("Authentication") || cloneResult.stderr.includes("403") + : cloneResult.stderr.includes("Authentication") || + cloneResult.stderr.includes("403") ? "Access denied (repository may be private)" : cloneResult.stderr.split("\n")[0] || "Clone failed"; editor.setStatus(`Failed to clone repository: ${errorMsg}`); @@ -756,7 +858,11 @@ async function installFromMonorepo( // Copy subdirectory to target editor.setStatus(`Installing ${packageName} from ${parsed.subpath}...`); - const copyResult = await editor.spawnProcess("cp", ["-r", subpathDir, targetDir]); + const copyResult = await editor.spawnProcess("cp", [ + "-r", + subpathDir, + targetDir, + ]); if (copyResult.exit_code !== 0) { editor.setStatus(`Failed to copy package: ${copyResult.stderr}`); await editor.spawnProcess("rm", ["-rf", tempDir]); @@ -766,7 +872,9 @@ async function installFromMonorepo( // Validate package structure const validation = validatePackage(targetDir, packageName); if (!validation.valid) { - editor.warn(`[pkg] Invalid package '${packageName}': ${validation.error}`); + editor.warn( + `[pkg] Invalid package '${packageName}': ${validation.error}`, + ); editor.setStatus(`Failed to install ${packageName}: ${validation.error}`); // Clean up the invalid package await editor.spawnProcess("rm", ["-rf", targetDir]); @@ -779,24 +887,41 @@ async function installFromMonorepo( repository: parsed.repoUrl, subpath: parsed.subpath, installed_from: `${parsed.repoUrl}#${parsed.subpath}`, - installed_at: new Date().toISOString() + installed_at: new Date().toISOString(), }; - await writeJsonFile(editor.pathJoin(targetDir, ".fresh-source.json"), sourceInfo); + await writeJsonFile( + editor.pathJoin(targetDir, ".fresh-source.json"), + sourceInfo, + ); const manifest = validation.manifest; // Dynamically load plugins, reload themes, or load language packs if (manifest?.type === "plugin" && validation.entryPath) { await editor.loadPlugin(validation.entryPath); - editor.setStatus(`Installed and activated ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed and activated ${packageName}${ + manifest ? ` v${manifest.version}` : "" + }`, + ); } else if (manifest?.type === "theme") { editor.reloadThemes(); - editor.setStatus(`Installed theme ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed theme ${packageName}${ + manifest ? ` v${manifest.version}` : "" + }`, + ); } else if (manifest?.type === "language") { await loadLanguagePack(targetDir, manifest); - editor.setStatus(`Installed language pack ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed language pack ${packageName}${ + manifest ? ` v${manifest.version}` : "" + }`, + ); } else { - editor.setStatus(`Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`); + editor.setStatus( + `Installed ${packageName}${manifest ? ` v${manifest.version}` : ""}`, + ); } return true; } finally { @@ -808,12 +933,18 @@ async function installFromMonorepo( /** * Load a language pack (register grammar, language config, and LSP server) */ -async function loadLanguagePack(packageDir: string, manifest: PackageManifest): Promise { +async function loadLanguagePack( + packageDir: string, + manifest: PackageManifest, +): Promise { const langId = manifest.name; // Register grammar if present if (manifest.fresh?.grammar) { - const grammarPath = editor.pathJoin(packageDir, manifest.fresh.grammar.file); + const grammarPath = editor.pathJoin( + packageDir, + manifest.fresh.grammar.file, + ); const extensions = manifest.fresh.grammar.extensions || []; editor.registerGrammar(langId, grammarPath, extensions); } @@ -829,10 +960,12 @@ async function loadLanguagePack(packageDir: string, manifest: PackageManifest): tabSize: lang.tabSize ?? null, autoIndent: lang.autoIndent ?? null, showWhitespaceTabs: lang.showWhitespaceTabs ?? null, - formatter: lang.formatter ? { - command: lang.formatter.command, - args: lang.formatter.args ?? [], - } : null, + formatter: lang.formatter + ? { + command: lang.formatter.command, + args: lang.formatter.args ?? [], + } + : null, }); } @@ -854,13 +987,21 @@ async function loadLanguagePack(packageDir: string, manifest: PackageManifest): /** * Checkout a specific version in a package directory */ -async function checkoutVersion(pkgPath: string, version: string): Promise { +async function checkoutVersion( + pkgPath: string, + version: string, +): Promise { let target: string; if (version === "latest") { // Get latest tag - const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]); - const tags = tagsResult.stdout.split("\n").filter(t => t.trim()); + const tagsResult = await gitCommand([ + "-C", + `${pkgPath}`, + "tag", + "--sort=-v:refname", + ]); + const tags = tagsResult.stdout.split("\n").filter((t) => t.trim()); target = tags[0] || "HEAD"; } else if (version.startsWith("^") || version.startsWith("~")) { // Semver matching - find best matching tag @@ -884,18 +1025,26 @@ async function checkoutVersion(pkgPath: string, version: string): Promise { - const tagsResult = await gitCommand(["-C", `${pkgPath}`, "tag", "--sort=-v:refname"]); - const tags = tagsResult.stdout.split("\n").filter(t => t.trim()); +async function findMatchingSemver( + pkgPath: string, + spec: string, +): Promise { + const tagsResult = await gitCommand([ + "-C", + `${pkgPath}`, + "tag", + "--sort=-v:refname", + ]); + const tags = tagsResult.stdout.split("\n").filter((t) => t.trim()); // Simple semver matching (^ means compatible, ~ means patch only) const prefix = spec.startsWith("^") ? "^" : "~"; const baseVersion = spec.slice(1); - const [major, minor] = baseVersion.split(".").map(n => parseInt(n, 10)); + const [major, minor] = baseVersion.split(".").map((n) => parseInt(n, 10)); for (const tag of tags) { const version = tag.replace(/^v/, ""); - const [tagMajor, tagMinor] = version.split(".").map(n => parseInt(n, 10)); + const [tagMajor, tagMinor] = version.split(".").map((n) => parseInt(n, 10)); if (prefix === "^") { // Compatible: same major @@ -930,7 +1079,9 @@ async function updatePackage(pkg: InstalledPackage): Promise { // Use listPlugins to find the correct runtime plugin name if (pkg.type === "plugin") { const loadedPlugins = await editor.listPlugins(); - const plugin = loadedPlugins.find((p: { path: string }) => p.path.startsWith(pkg.path)); + const plugin = loadedPlugins.find((p: { path: string }) => + p.path.startsWith(pkg.path) + ); if (plugin) { await editor.reloadPlugin(plugin.name); } @@ -943,7 +1094,8 @@ async function updatePackage(pkg: InstalledPackage): Promise { } else { const errorMsg = result.stderr.includes("Could not resolve host") ? "Network error" - : result.stderr.includes("Authentication") || result.stderr.includes("403") + : result.stderr.includes("Authentication") || + result.stderr.includes("403") ? "Authentication failed" : result.stderr.split("\n")[0] || "Update failed"; editor.setStatus(`Failed to update ${pkg.name}: ${errorMsg}`); @@ -961,7 +1113,9 @@ async function removePackage(pkg: InstalledPackage): Promise { // Use listPlugins to find the correct runtime plugin name by matching path if (pkg.type === "plugin") { const loadedPlugins = await editor.listPlugins(); - const plugin = loadedPlugins.find((p: { path: string }) => p.path.startsWith(pkg.path)); + const plugin = loadedPlugins.find((p: { path: string }) => + p.path.startsWith(pkg.path) + ); if (plugin) { await editor.unloadPlugin(plugin.name).catch(() => {}); } @@ -1003,7 +1157,9 @@ async function updateAllPackages(): Promise { let failed = 0; for (const pkg of all) { - editor.setStatus(`Updating ${pkg.name} (${updated + failed + 1}/${all.length})...`); + editor.setStatus( + `Updating ${pkg.name} (${updated + failed + 1}/${all.length})...`, + ); const result = await gitCommand(["-C", `${pkg.path}`, "pull", "--ff-only"]); if (result.exit_code === 0) { @@ -1015,7 +1171,11 @@ async function updateAllPackages(): Promise { } } - editor.setStatus(`Update complete: ${updated} updated, ${all.length - updated - failed} unchanged, ${failed} failed`); + editor.setStatus( + `Update complete: ${updated} updated, ${ + all.length - updated - failed + } unchanged, ${failed} failed`, + ); } // ============================================================================= @@ -1035,18 +1195,23 @@ async function generateLockfile(): Promise { const lockfile: Lockfile = { lockfile_version: 1, generated: new Date().toISOString(), - packages: {} + packages: {}, }; for (const pkg of all) { // Get current commit - const commitResult = await gitCommand(["-C", `${pkg.path}`, "rev-parse", "HEAD"]); + const commitResult = await gitCommand([ + "-C", + `${pkg.path}`, + "rev-parse", + "HEAD", + ]); const commit = commitResult.stdout.trim(); lockfile.packages[pkg.name] = { source: pkg.source, commit, - version: pkg.version + version: pkg.version, }; } @@ -1073,7 +1238,11 @@ async function installFromLockfile(): Promise { let failed = 0; for (const [name, entry] of Object.entries(lockfile.packages)) { - editor.setStatus(`Installing ${name} (${installed + failed + 1}/${Object.keys(lockfile.packages).length})...`); + editor.setStatus( + `Installing ${name} (${installed + failed + 1}/${ + Object.keys(lockfile.packages).length + })...`, + ); // Check if already installed const pluginPath = editor.pathJoin(PACKAGES_DIR, name); @@ -1083,7 +1252,12 @@ async function installFromLockfile(): Promise { // Already installed, just checkout the commit const path = editor.fileExists(pluginPath) ? pluginPath : themePath; await gitCommand(["-C", `${path}`, "fetch"]); - const result = await gitCommand(["-C", `${path}`, "checkout", entry.commit]); + const result = await gitCommand([ + "-C", + `${path}`, + "checkout", + entry.commit, + ]); if (result.exit_code === 0) { installed++; } else { @@ -1092,7 +1266,11 @@ async function installFromLockfile(): Promise { } else { // Need to clone await ensureDir(PACKAGES_DIR); - const result = await gitCommand(["clone", `${entry.source}`, `${pluginPath}`]); + const result = await gitCommand([ + "clone", + `${entry.source}`, + `${pluginPath}`, + ]); if (result.exit_code === 0) { await gitCommand(["-C", `${pluginPath}`, "checkout", entry.commit]); @@ -1103,7 +1281,9 @@ async function installFromLockfile(): Promise { } } - editor.setStatus(`Lockfile install complete: ${installed} installed, ${failed} failed`); + editor.setStatus( + `Lockfile install complete: ${installed} installed, ${failed} failed`, + ); } // ============================================================================= @@ -1134,11 +1314,11 @@ interface PackageListItem { // Focus target types for Tab navigation type FocusTarget = - | { type: "filter"; index: number } // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages + | { type: "filter"; index: number } // 0=All, 1=Installed, 2=Plugins, 3=Themes, 4=Languages | { type: "sync" } | { type: "search" } - | { type: "list" } // Package list (use arrows to navigate) - | { type: "action"; index: number }; // Action buttons for selected package + | { type: "list" } // Package list (use arrows to navigate) + | { type: "action"; index: number }; // Action buttons for selected package interface PkgManagerState { isOpen: boolean; @@ -1149,7 +1329,7 @@ interface PkgManagerState { searchQuery: string; items: PackageListItem[]; selectedIndex: number; - focus: FocusTarget; // What element has Tab focus + focus: FocusTarget; // What element has Tab focus isLoading: boolean; } @@ -1183,7 +1363,7 @@ const pkgTheme: Record = { available: { fg: { theme: "editor.fg", rgb: [200, 200, 210] } }, selected: { fg: { theme: "ui.menu_active_fg", rgb: [255, 255, 255] }, - bg: { theme: "ui.menu_active_bg", rgb: [50, 80, 120] } + bg: { theme: "ui.menu_active_bg", rgb: [50, 80, 120] }, }, // Descriptions and details @@ -1201,14 +1381,14 @@ const pkgTheme: Record = { // Filter buttons filterActive: { fg: { rgb: [255, 255, 255] }, - bg: { theme: "syntax.keyword", rgb: [60, 100, 160] } + bg: { theme: "syntax.keyword", rgb: [60, 100, 160] }, }, filterInactive: { fg: { rgb: [160, 160, 170] }, }, filterFocused: { fg: { rgb: [255, 255, 255] }, - bg: { rgb: [80, 80, 90] } + bg: { rgb: [80, 80, 90] }, }, // Action buttons @@ -1217,17 +1397,17 @@ const pkgTheme: Record = { }, buttonFocused: { fg: { rgb: [255, 255, 255] }, - bg: { theme: "syntax.keyword", rgb: [60, 110, 180] } + bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }, }, // Search box - distinct input field appearance searchBox: { fg: { rgb: [200, 200, 210] }, - bg: { rgb: [40, 42, 48] } + bg: { rgb: [40, 42, 48] }, }, searchBoxFocused: { fg: { rgb: [255, 255, 255] }, - bg: { theme: "syntax.keyword", rgb: [60, 110, 180] } + bg: { theme: "syntax.keyword", rgb: [60, 110, 180] }, }, // Status indicators @@ -1236,7 +1416,12 @@ const pkgTheme: Record = { }; /** Extract theme colors with fallback to RGB - simplifies theme access */ -function themeColor(style: ThemeColor): { fg?: string | [number, number, number]; bg?: string | [number, number, number] } { +function themeColor( + style: ThemeColor, +): { + fg?: string | [number, number, number]; + bg?: string | [number, number, number]; +} { return { fg: style.fg?.theme ?? style.fg?.rgb, bg: style.bg?.theme ?? style.bg?.rgb, @@ -1244,12 +1429,16 @@ function themeColor(style: ThemeColor): { fg?: string | [number, number, number] } /** Get fg color from theme style */ -function themeFg(style: ThemeColor): string | [number, number, number] | undefined { +function themeFg( + style: ThemeColor, +): string | [number, number, number] | undefined { return style.fg?.theme ?? style.fg?.rgb; } /** Get bg color from theme style */ -function themeBg(style: ThemeColor): string | [number, number, number] | undefined { +function themeBg( + style: ThemeColor, +): string | [number, number, number] | undefined { return style.bg?.theme ?? style.bg?.rgb; } @@ -1266,7 +1455,7 @@ editor.defineMode( ["Escape", "pkg_back_or_close"], ["/", "pkg_search"], ], - true // read-only + true, // read-only ); // Define pkg-detail mode for package details view @@ -1281,7 +1470,7 @@ editor.defineMode( ["S-Tab", "pkg_prev_button"], ["Escape", "pkg_back_or_close"], ], - true // read-only + true, // read-only ); /** @@ -1296,7 +1485,13 @@ function buildPackageList(): PackageListItem[] { const installedLanguages = getInstalledPackages("language"); const installedMap = new Map(); - for (const pkg of [...installedPlugins, ...installedThemes, ...installedLanguages]) { + for ( + const pkg of [ + ...installedPlugins, + ...installedThemes, + ...installedLanguages, + ] + ) { installedMap.set(pkg.name, pkg); items.push({ type: "installed", @@ -1399,26 +1594,26 @@ function getFilteredItems(): PackageListItem[] { // Apply filter switch (pkgState.filter) { case "installed": - items = items.filter(i => i.installed); + items = items.filter((i) => i.installed); break; case "plugins": - items = items.filter(i => i.packageType === "plugin"); + items = items.filter((i) => i.packageType === "plugin"); break; case "themes": - items = items.filter(i => i.packageType === "theme"); + items = items.filter((i) => i.packageType === "theme"); break; case "languages": - items = items.filter(i => i.packageType === "language"); + items = items.filter((i) => i.packageType === "language"); break; } // Apply search (case insensitive) if (pkgState.searchQuery) { const query = pkgState.searchQuery.toLowerCase(); - items = items.filter(i => + items = items.filter((i) => i.name.toLowerCase().includes(query) || (i.description && i.description.toLowerCase().includes(query)) || - (i.keywords && i.keywords.some(k => k.toLowerCase().includes(query))) + (i.keywords && i.keywords.some((k) => k.toLowerCase().includes(query))) ); } @@ -1444,7 +1639,7 @@ function formatNumber(n: number | undefined): string { } // Layout constants -const LIST_WIDTH = 36; // Width of left panel (package list) +const LIST_WIDTH = 36; // Width of left panel (package list) const TOTAL_WIDTH = 88; // Total width of UI const DETAIL_WIDTH = TOTAL_WIDTH - LIST_WIDTH - 3; // Right panel width (minus divider) @@ -1487,7 +1682,9 @@ function wrapText(text: string, maxWidth: number): string[] { currentLine += (currentLine ? " " : "") + word; } else { if (currentLine) lines.push(currentLine); - currentLine = word.length > maxWidth ? word.slice(0, maxWidth - 1) + "…" : word; + currentLine = word.length > maxWidth + ? word.slice(0, maxWidth - 1) + "…" + : word; } } if (currentLine) lines.push(currentLine); @@ -1506,9 +1703,10 @@ function renderPkgManagerUI(): void { const builder = new VirtualBufferBuilder(pkgState.bufferId, "pkg"); const items = getFilteredItems(); const selectedItem = items.length > 0 && pkgState.selectedIndex < items.length - ? items[pkgState.selectedIndex] : null; - const installedItems = items.filter(i => i.installed); - const availableItems = items.filter(i => !i.installed); + ? items[pkgState.selectedIndex] + : null; + const installedItems = items.filter((i) => i.installed); + const availableItems = items.filter((i) => !i.installed); // === HEADER === builder.styled(" Packages\n", themeFg(pkgTheme.header)); @@ -1517,100 +1715,133 @@ function renderPkgManagerUI(): void { // === SEARCH BAR === const searchFocused = isButtonFocused("search"); const searchText = pkgState.searchQuery || ""; - const searchDisplay = searchText.length > 29 ? searchText.slice(0, 27) + "…" : searchText.padEnd(30); - const searchStyle = themeColor(searchFocused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox); + const searchDisplay = searchText.length > 29 + ? searchText.slice(0, 27) + "…" + : searchText.padEnd(30); + const searchStyle = themeColor( + searchFocused ? pkgTheme.searchBoxFocused : pkgTheme.searchBox, + ); builder.styled(" Search: ", themeFg(pkgTheme.infoLabel)); - builder.styled(searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `, searchStyle.fg, searchStyle.bg); + builder.styled( + searchFocused ? `[${searchDisplay}]` : ` ${searchDisplay} `, + searchStyle.fg, + searchStyle.bg, + ); builder.newline(); // === FILTER BAR === - const filters = ["All", "Installed", "Plugins", "Themes", "Languages"]; - const filterIds = ["all", "installed", "plugins", "themes", "languages"]; - - builder.text(" "); - for (let i = 0; i < filters.length; i++) { - const isActive = pkgState.filter === filterIds[i]; - const isFocused = isButtonFocused("filter", i); - const style = themeColor( - isFocused ? (isActive ? pkgTheme.buttonFocused : pkgTheme.filterFocused) - : (isActive ? pkgTheme.filterActive : pkgTheme.filterInactive) - ); - const btn = new ButtonControl(filters[i], isFocused ? FocusState.Focused : FocusState.Normal); - builder.styled(btn.render().text, style.fg, style.bg); - } - - builder.text(" "); + const filterOptions: FilterOption[] = [ + { id: "all", label: "All" }, + { id: "installed", label: "Installed" }, + { id: "plugins", label: "Plugins" }, + { id: "themes", label: "Themes" }, + { id: "languages", label: "Languages" }, + ]; + const focusedFilterIdx = pkgState.focus.type === "filter" + ? pkgState.focus.index + : -1; + const filterBar = new FilterBar( + filterOptions, + pkgState.filter, + focusedFilterIdx, + { + activeFg: themeFg(pkgTheme.filterActive), + activeBg: themeBg(pkgTheme.filterActive), + inactiveFg: themeFg(pkgTheme.filterInactive), + focusedFg: themeFg(pkgTheme.filterFocused), + focusedBg: themeBg(pkgTheme.filterFocused), + }, + ); + builder.text(" ").control(filterBar.render()).text(" "); // Sync button const syncFocused = isButtonFocused("sync"); - const syncStyle = themeColor(syncFocused ? pkgTheme.buttonFocused : pkgTheme.button); - builder.styled(new ButtonControl("Sync", syncFocused ? FocusState.Focused : FocusState.Normal).render().text, syncStyle.fg, syncStyle.bg); + const syncStyle = themeColor( + syncFocused ? pkgTheme.buttonFocused : pkgTheme.button, + ); + builder.styled( + new ButtonControl( + "Sync", + syncFocused ? FocusState.Focused : FocusState.Normal, + ).render().text, + syncStyle.fg, + syncStyle.bg, + ); builder.newline(); // === TOP SEPARATOR === - builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", themeFg(pkgTheme.separator)); + builder.styled( + " " + "─".repeat(TOTAL_WIDTH - 2) + "\n", + themeFg(pkgTheme.separator), + ); // === SPLIT VIEW: Package list on left, Details on right === const leftLines = buildLeftPanel(installedItems, availableItems, items); const rightLines = buildRightPanel(selectedItem); - // Merge left and right panels into rows - const maxRows = Math.max(leftLines.length, rightLines.length, 8); - for (let i = 0; i < maxRows; i++) { - const leftItem = leftLines[i]; - const rightItem = rightLines[i]; - - // Left side (padded to fixed width) - const leftText = leftItem ? (" " + leftItem.text).padEnd(LIST_WIDTH) : " ".repeat(LIST_WIDTH); - if (leftItem) { - builder.styled(leftText, leftItem.fg, leftItem.bg); - } else { - builder.text(leftText); - } - - // Divider - builder.styled("│", themeFg(pkgTheme.divider)); - - // Right side - const rightText = rightItem ? " " + rightItem.text : ""; - if (rightItem) { - builder.styled(rightText, rightItem.fg, rightItem.bg); - } else { - builder.text(rightText); - } - - builder.newline(); - } + const splitView = new SplitView(leftLines, rightLines, { + leftWidth: LIST_WIDTH, + divider: "│", + dividerFg: themeFg(pkgTheme.divider), + minRows: 8, + leftPadding: " ", + rightPadding: " ", + }); + builder.control(splitView.render()); // === BOTTOM SEPARATOR === - builder.styled(" " + "─".repeat(TOTAL_WIDTH - 2) + "\n", themeFg(pkgTheme.separator)); + builder.styled( + " " + "─".repeat(TOTAL_WIDTH - 2) + "\n", + themeFg(pkgTheme.separator), + ); // === HELP LINE === - const actionLabel = { action: "Activate", filter: "Filter", sync: "Sync", search: "Search", list: "Select" }[pkgState.focus.type] || "Select"; - builder.styled(` ↑↓ Navigate Tab Next / Search Enter ${actionLabel} Esc Close\n`, themeFg(pkgTheme.help)); + const actionLabel = { + action: "Activate", + filter: "Filter", + sync: "Sync", + search: "Search", + list: "Select", + }[pkgState.focus.type] || "Select"; + const helpBindings: KeyBinding[] = [ + { key: "↑↓", action: "Navigate" }, + { key: "Tab", action: "Next" }, + { key: "/", action: "Search" }, + { key: "Enter", action: actionLabel }, + { key: "Esc", action: "Close" }, + ]; + const helpBar = new HelpBar(helpBindings, { fg: themeFg(pkgTheme.help) }); + builder.control(helpBar.render()).newline(); // Build and apply to buffer builder.build(); } -/** Line item for the split-view panels */ -interface PanelLine { - text: string; - fg?: string | [number, number, number]; - bg?: string | [number, number, number]; -} - /** Format a package item for the list - handles both installed and available */ -function formatPackageItem(item: PackageListItem, _selected: boolean, _index: number): string { +function formatPackageItem( + item: PackageListItem, + _selected: boolean, + _index: number, +): string { if (item.installed) { const status = item.updateAvailable ? "↑" : "✓"; - const ver = item.version.length > 7 ? item.version.slice(0, 6) + "…" : item.version; - const name = item.name.length > 18 ? item.name.slice(0, 17) + "…" : item.name; + const ver = item.version.length > 7 + ? item.version.slice(0, 6) + "…" + : item.version; + const name = item.name.length > 18 + ? item.name.slice(0, 17) + "…" + : item.name; return `${name.padEnd(18)} ${ver.padEnd(7)} ${status}`; } else { - const typeTag = item.packageType === "theme" ? "T" : item.packageType === "language" ? "L" : "P"; - const name = item.name.length > 22 ? item.name.slice(0, 21) + "…" : item.name; + const typeTag = item.packageType === "theme" + ? "T" + : item.packageType === "language" + ? "L" + : "P"; + const name = item.name.length > 22 + ? item.name.slice(0, 21) + "…" + : item.name; return `${name.padEnd(22)} [${typeTag}]`; } } @@ -1619,7 +1850,7 @@ function formatPackageItem(item: PackageListItem, _selected: boolean, _index: nu function buildLeftPanel( installedItems: PackageListItem[], availableItems: PackageListItem[], - allItems: PackageListItem[] + allItems: PackageListItem[], ): PanelLine[] { // Empty state if (allItems.length === 0) { @@ -1639,23 +1870,33 @@ function buildLeftPanel( const groups: ListGroup[] = []; if (installedItems.length > 0) { - groups.push({ title: `INSTALLED (${installedItems.length})`, items: installedItems }); + groups.push({ + title: `INSTALLED (${installedItems.length})`, + items: installedItems, + }); } if (availableItems.length > 0) { - groups.push({ title: `AVAILABLE (${availableItems.length})`, items: availableItems }); + groups.push({ + title: `AVAILABLE (${availableItems.length})`, + items: availableItems, + }); } - const list = new GroupedListControl(groups, formatPackageItem, { - selectionPrefix: listFocused ? "▸ " : " ", - emptyPrefix: " ", - titleFg: themeFg(pkgTheme.sectionTitle), - selectedFg: themeFg(pkgTheme.selected), - selectedBg: themeBg(pkgTheme.selected), - }); + const list = new GroupedListControl( + groups, + formatPackageItem, + { + selectionPrefix: listFocused ? "▸ " : " ", + emptyPrefix: " ", + titleFg: themeFg(pkgTheme.sectionTitle), + selectedFg: themeFg(pkgTheme.selected), + selectedBg: themeBg(pkgTheme.selected), + }, + ); list.selectedIndex = pkgState.selectedIndex; // Convert renderLines output to PanelLine format - return list.renderLines().map(line => ({ + return list.renderLines().map((line) => ({ text: line.text, fg: line.fg as PanelLine["fg"], bg: line.bg as PanelLine["bg"], @@ -1669,13 +1910,20 @@ function buildRightPanel(selectedItem: PackageListItem | null): PanelLine[] { if (selectedItem) { // Package name lines.push({ text: selectedItem.name, fg: themeFg(pkgTheme.header) }); - lines.push({ text: "─".repeat(Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2)), fg: themeFg(pkgTheme.separator) }); + lines.push({ + text: "─".repeat( + Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2), + ), + fg: themeFg(pkgTheme.separator), + }); // Version / Author / License let metaLine = `v${selectedItem.version}`; if (selectedItem.author) metaLine += ` • ${selectedItem.author}`; if (selectedItem.license) metaLine += ` • ${selectedItem.license}`; - if (metaLine.length > DETAIL_WIDTH - 2) metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "..."; + if (metaLine.length > DETAIL_WIDTH - 2) { + metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "..."; + } lines.push({ text: metaLine, fg: themeFg(pkgTheme.infoLabel) }); lines.push({ text: "" }); @@ -1690,14 +1938,20 @@ function buildRightPanel(selectedItem: PackageListItem | null): PanelLine[] { // Keywords if (selectedItem.keywords && selectedItem.keywords.length > 0) { - lines.push({ text: `Tags: ${selectedItem.keywords.slice(0, 4).join(", ")}`, fg: themeFg(pkgTheme.infoLabel) }); + lines.push({ + text: `Tags: ${selectedItem.keywords.slice(0, 4).join(", ")}`, + fg: themeFg(pkgTheme.infoLabel), + }); lines.push({ text: "" }); } // Repository URL if (selectedItem.repository) { - let displayUrl = selectedItem.repository.replace(/^https?:\/\//, "").replace(/\.git$/, ""); - if (displayUrl.length > DETAIL_WIDTH - 2) displayUrl = displayUrl.slice(0, DETAIL_WIDTH - 5) + "..."; + let displayUrl = selectedItem.repository.replace(/^https?:\/\//, "") + .replace(/\.git$/, ""); + if (displayUrl.length > DETAIL_WIDTH - 2) { + displayUrl = displayUrl.slice(0, DETAIL_WIDTH - 5) + "..."; + } lines.push({ text: displayUrl, fg: themeFg(pkgTheme.infoLabel) }); lines.push({ text: "" }); } @@ -1705,8 +1959,17 @@ function buildRightPanel(selectedItem: PackageListItem | null): PanelLine[] { // Action buttons for (let i = 0; i < getActionButtons().length; i++) { const focused = isButtonFocused("action", i); - const style = themeColor(focused ? pkgTheme.buttonFocused : pkgTheme.button); - lines.push({ text: new ButtonControl(getActionButtons()[i], focused ? FocusState.Focused : FocusState.Normal).render().text, fg: style.fg, bg: style.bg }); + const style = themeColor( + focused ? pkgTheme.buttonFocused : pkgTheme.button, + ); + lines.push({ + text: new ButtonControl( + getActionButtons()[i], + focused ? FocusState.Focused : FocusState.Normal, + ).render().text, + fg: style.fg, + bg: style.bg, + }); } } else { lines.push({ text: "Select a package", fg: themeFg(pkgTheme.emptyState) }); @@ -1716,7 +1979,6 @@ function buildRightPanel(selectedItem: PackageListItem | null): PanelLine[] { return lines; } - /** * Update the package manager view */ @@ -1808,11 +2070,11 @@ function closePackageManager(): void { function getFocusOrder(): FocusTarget[] { const order: FocusTarget[] = [ { type: "search" }, - { type: "filter", index: 0 }, // All - { type: "filter", index: 1 }, // Installed - { type: "filter", index: 2 }, // Plugins - { type: "filter", index: 3 }, // Themes - { type: "filter", index: 4 }, // Languages + { type: "filter", index: 0 }, // All + { type: "filter", index: 1 }, // Installed + { type: "filter", index: 2 }, // Plugins + { type: "filter", index: 3 }, // Themes + { type: "filter", index: 4 }, // Languages { type: "sync" }, { type: "list" }, ]; @@ -1845,7 +2107,7 @@ function getCurrentFocusIndex(): number { } // Navigation commands -globalThis.pkg_nav_up = function(): void { +globalThis.pkg_nav_up = function (): void { if (!pkgState.isOpen) return; const items = getFilteredItems(); @@ -1857,19 +2119,22 @@ globalThis.pkg_nav_up = function(): void { updatePkgManagerView(); }; -globalThis.pkg_nav_down = function(): void { +globalThis.pkg_nav_down = function (): void { if (!pkgState.isOpen) return; const items = getFilteredItems(); if (items.length === 0) return; // Always focus list and navigate (auto-focus behavior) - pkgState.selectedIndex = Math.min(items.length - 1, pkgState.selectedIndex + 1); + pkgState.selectedIndex = Math.min( + items.length - 1, + pkgState.selectedIndex + 1, + ); pkgState.focus = { type: "list" }; updatePkgManagerView(); }; -globalThis.pkg_next_button = function(): void { +globalThis.pkg_next_button = function (): void { if (!pkgState.isOpen) return; const order = getFocusOrder(); @@ -1879,7 +2144,7 @@ globalThis.pkg_next_button = function(): void { updatePkgManagerView(); }; -globalThis.pkg_prev_button = function(): void { +globalThis.pkg_prev_button = function (): void { if (!pkgState.isOpen) return; const order = getFocusOrder(); @@ -1889,14 +2154,20 @@ globalThis.pkg_prev_button = function(): void { updatePkgManagerView(); }; -globalThis.pkg_activate = async function(): Promise { +globalThis.pkg_activate = async function (): Promise { if (!pkgState.isOpen) return; const focus = pkgState.focus; // Handle filter button activation if (focus.type === "filter") { - const filters = ["all", "installed", "plugins", "themes", "languages"] as const; + const filters = [ + "all", + "installed", + "plugins", + "themes", + "languages", + ] as const; pkgState.filter = filters[focus.index]; pkgState.selectedIndex = 0; pkgState.items = buildPackageList(); @@ -1952,18 +2223,25 @@ globalThis.pkg_activate = async function(): Promise { await removePackage(item.installedPackage); pkgState.items = buildPackageList(); const newItems = getFilteredItems(); - pkgState.selectedIndex = Math.min(pkgState.selectedIndex, Math.max(0, newItems.length - 1)); + pkgState.selectedIndex = Math.min( + pkgState.selectedIndex, + Math.max(0, newItems.length - 1), + ); pkgState.focus = { type: "list" }; updatePkgManagerView(); } else if (actionName === "Install" && item.registryEntry) { - await installPackage(item.registryEntry.repository, item.name, item.packageType); + await installPackage( + item.registryEntry.repository, + item.name, + item.packageType, + ); pkgState.items = buildPackageList(); updatePkgManagerView(); } } }; -globalThis.pkg_back_or_close = function(): void { +globalThis.pkg_back_or_close = function (): void { if (!pkgState.isOpen) return; // If focus is on action buttons, go back to list @@ -1977,28 +2255,32 @@ globalThis.pkg_back_or_close = function(): void { closePackageManager(); }; -globalThis.pkg_scroll_up = function(): void { +globalThis.pkg_scroll_up = function (): void { // Just move cursor up in detail view editor.executeAction("move_up"); }; -globalThis.pkg_scroll_down = function(): void { +globalThis.pkg_scroll_down = function (): void { // Just move cursor down in detail view editor.executeAction("move_down"); }; -globalThis.pkg_search = function(): void { +globalThis.pkg_search = function (): void { if (!pkgState.isOpen) return; // Pre-fill with current search query so typing replaces it if (pkgState.searchQuery) { - editor.startPromptWithInitial("Search packages: ", "pkg-search", pkgState.searchQuery); + editor.startPromptWithInitial( + "Search packages: ", + "pkg-search", + pkgState.searchQuery, + ); } else { editor.startPrompt("Search packages: ", "pkg-search"); } }; -globalThis.onPkgSearchConfirmed = function(args: { +globalThis.onPkgSearchConfirmed = function (args: { prompt_type: string; selected_index: number | null; input: string; @@ -2021,13 +2303,13 @@ const registryFinder = new Finder<[string, RegistryEntry]>(editor, { format: ([name, entry]) => ({ label: name, description: entry.description, - metadata: { name, entry } + metadata: { name, entry }, }), preview: false, maxResults: 100, onSelect: async ([name, entry]) => { await installPackage(entry.repository, name, "plugin"); - } + }, }); // ============================================================================= @@ -2037,14 +2319,18 @@ const registryFinder = new Finder<[string, RegistryEntry]>(editor, { /** * Browse and install plugins from registry */ -globalThis.pkg_install_plugin = async function(): Promise { +globalThis.pkg_install_plugin = async function (): Promise { editor.debug("[pkg] pkg_install_plugin called"); try { // Always sync registry to ensure latest plugins are available await syncRegistry(); const registry = loadRegistry("plugins"); - editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} packages`); + editor.debug( + `[pkg] loaded registry with ${ + Object.keys(registry.packages).length + } packages`, + ); const entries = Object.entries(registry.packages); editor.debug(`[pkg] entries.length = ${entries.length}`); @@ -2060,8 +2346,8 @@ globalThis.pkg_install_plugin = async function(): Promise { title: "Install Plugin:", source: { mode: "filter", - load: async () => entries - } + load: async () => entries, + }, }); } catch (e) { editor.debug(`[pkg] Error in pkg_install_plugin: ${e}`); @@ -2072,14 +2358,18 @@ globalThis.pkg_install_plugin = async function(): Promise { /** * Browse and install themes from registry */ -globalThis.pkg_install_theme = async function(): Promise { +globalThis.pkg_install_theme = async function (): Promise { editor.debug("[pkg] pkg_install_theme called"); try { // Always sync registry to ensure latest themes are available await syncRegistry(); const registry = loadRegistry("themes"); - editor.debug(`[pkg] loaded registry with ${Object.keys(registry.packages).length} themes`); + editor.debug( + `[pkg] loaded registry with ${ + Object.keys(registry.packages).length + } themes`, + ); const entries = Object.entries(registry.packages); if (entries.length === 0) { @@ -2091,8 +2381,8 @@ globalThis.pkg_install_theme = async function(): Promise { title: "Install Theme:", source: { mode: "filter", - load: async () => entries - } + load: async () => entries, + }, }); } catch (e) { editor.debug(`[pkg] Error in pkg_install_theme: ${e}`); @@ -2103,11 +2393,11 @@ globalThis.pkg_install_theme = async function(): Promise { /** * Install from git URL */ -globalThis.pkg_install_url = function(): void { +globalThis.pkg_install_url = function (): void { editor.startPrompt("Git URL:", "pkg-install-url"); }; -globalThis.onPkgInstallUrlConfirmed = async function(args: { +globalThis.onPkgInstallUrlConfirmed = async function (args: { prompt_type: string; selected_index: number | null; input: string; @@ -2129,21 +2419,21 @@ editor.on("prompt_confirmed", "onPkgInstallUrlConfirmed"); /** * Open the package manager UI */ -globalThis.pkg_list = async function(): Promise { +globalThis.pkg_list = async function (): Promise { await openPackageManager(); }; /** * Update all packages */ -globalThis.pkg_update_all = async function(): Promise { +globalThis.pkg_update_all = async function (): Promise { await updateAllPackages(); }; /** * Update a specific package */ -globalThis.pkg_update = function(): void { +globalThis.pkg_update = function (): void { const plugins = getInstalledPackages("plugin"); const themes = getInstalledPackages("theme"); const all = [...plugins, ...themes]; @@ -2158,27 +2448,27 @@ globalThis.pkg_update = function(): void { format: (pkg) => ({ label: pkg.name, description: `${pkg.type} | ${pkg.version}`, - metadata: pkg + metadata: pkg, }), preview: false, onSelect: async (pkg) => { await updatePackage(pkg); - } + }, }); finder.prompt({ title: "Update Package:", source: { mode: "filter", - load: async () => all - } + load: async () => all, + }, }); }; /** * Remove a package */ -globalThis.pkg_remove = function(): void { +globalThis.pkg_remove = function (): void { const plugins = getInstalledPackages("plugin"); const themes = getInstalledPackages("theme"); const all = [...plugins, ...themes]; @@ -2193,34 +2483,34 @@ globalThis.pkg_remove = function(): void { format: (pkg) => ({ label: pkg.name, description: `${pkg.type} | ${pkg.version}`, - metadata: pkg + metadata: pkg, }), preview: false, onSelect: async (pkg) => { await removePackage(pkg); - } + }, }); finder.prompt({ title: "Remove Package:", source: { mode: "filter", - load: async () => all - } + load: async () => all, + }, }); }; /** * Sync registry */ -globalThis.pkg_sync = async function(): Promise { +globalThis.pkg_sync = async function (): Promise { await syncRegistry(); }; /** * Show outdated packages */ -globalThis.pkg_outdated = async function(): Promise { +globalThis.pkg_outdated = async function (): Promise { const plugins = getInstalledPackages("plugin"); const themes = getInstalledPackages("theme"); const all = [...plugins, ...themes]; @@ -2240,7 +2530,11 @@ globalThis.pkg_outdated = async function(): Promise { // Check how many commits behind const result = await gitCommand([ - "-C", `${pkg.path}`, "rev-list", "--count", "HEAD..origin/HEAD" + "-C", + `${pkg.path}`, + "rev-list", + "--count", + "HEAD..origin/HEAD", ]); const behind = parseInt(result.stdout.trim(), 10); @@ -2259,34 +2553,34 @@ globalThis.pkg_outdated = async function(): Promise { format: (item) => ({ label: item.pkg.name, description: `${item.behind} commits behind`, - metadata: item + metadata: item, }), preview: false, onSelect: async (item) => { await updatePackage(item.pkg); - } + }, }); finder.prompt({ title: `Outdated Packages (${outdated.length}):`, source: { mode: "filter", - load: async () => outdated - } + load: async () => outdated, + }, }); }; /** * Generate lockfile */ -globalThis.pkg_lock = async function(): Promise { +globalThis.pkg_lock = async function (): Promise { await generateLockfile(); }; /** * Install from lockfile */ -globalThis.pkg_install_lock = async function(): Promise { +globalThis.pkg_install_lock = async function (): Promise { await installFromLockfile(); }; @@ -2298,7 +2592,12 @@ globalThis.pkg_install_lock = async function(): Promise { editor.registerCommand("%cmd.list", "%cmd.list_desc", "pkg_list", null); // Install from URL - for packages not in registry -editor.registerCommand("%cmd.install_url", "%cmd.install_url_desc", "pkg_install_url", null); +editor.registerCommand( + "%cmd.install_url", + "%cmd.install_url_desc", + "pkg_install_url", + null, +); // Note: Other commands (install_plugin, install_theme, update, remove, sync, etc.) // are available via the package manager UI and don't need global command palette entries.