diff --git a/crates/fresh-editor/plugins/lib/controls.ts b/crates/fresh-editor/plugins/lib/controls.ts new file mode 100644 index 000000000..36cf39e0d --- /dev/null +++ b/crates/fresh-editor/plugins/lib/controls.ts @@ -0,0 +1,1135 @@ +/// + +/** + * 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; + private _titleFg: string | RGB; + private _selectedFg: string | RGB; + private _selectedBg: string | RGB; + private _itemFg?: string | RGB; + + constructor( + public groups: ListGroup[], + public renderItem: ItemRenderer, + options: { + 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; + } + + /** + * 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: this._titleFg, + 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: this._selectedFg, + bg: this._selectedBg, + }); + } else if (this._itemFg) { + styles.push({ + start: charOffset, + end: charOffset + line.length, + fg: this._itemFg, + }); + } + + charOffset += line.length + 1; + itemIndex++; + } + } + + return { + text: lines.join("\n"), + 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; + } +} + +// ============================================================================= +// 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 }; + } +} + +// ============================================================================= +// 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 52fd19924..73087197d 100644 --- a/crates/fresh-editor/plugins/lib/index.ts +++ b/crates/fresh-editor/plugins/lib/index.ts @@ -1,24 +1,46 @@ /** * 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(); * ``` */ // Types export type { - RGB, + FileExplorerDecoration, + HighlightPattern, Location, + NavigationOptions, PanelOptions, PanelState, - NavigationOptions, - HighlightPattern, - FileExplorerDecoration, + RGB, } from "./types.ts"; // Panel Management @@ -29,18 +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 { + ButtonControl, + FilterBar, + FocusManager, + FocusState, + GroupedListControl, + HelpBar, + Label, + ListControl, + Separator, + SplitView, + TextInputControl, + ToggleButton, +} from "./controls.ts"; +export type { + ControlOutput, + FilterOption, + ItemRenderer, + KeyBinding, + ListGroup, + PanelLine, + StyleRange, +} from "./controls.ts"; + +// Virtual Buffer Builder +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 new file mode 100644 index 000000000..74eb32d6f --- /dev/null +++ b/crates/fresh-editor/plugins/lib/vbuffer.ts @@ -0,0 +1,398 @@ +/// + +/** + * 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 { ControlOutput, StyleRange } 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/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 4f3e99eb2..b14038c52 100644 --- a/crates/fresh-editor/plugins/pkg.ts +++ b/crates/fresh-editor/plugins/pkg.ts @@ -13,22 +13,28 @@ * - 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, + FilterBar, + FocusState, + GroupedListControl, + HelpBar, + SplitView, + VirtualBufferBuilder, +} from "./lib/index.ts"; +import type { + FilterOption, + KeyBinding, + ListGroup, + PanelLine, +} from "./lib/index.ts"; const editor = getEditor(); @@ -39,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"); @@ -193,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; @@ -316,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 { @@ -339,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}`, + ); } } } @@ -354,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)`); } @@ -370,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; } @@ -409,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) { @@ -431,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; } @@ -446,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)) { @@ -483,7 +541,7 @@ function getInstalledPackages(type: "plugin" | "theme" | "language"): InstalledP type, source, version: manifest?.version || "unknown", - manifest + manifest, }); } } @@ -512,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}`, }; } @@ -528,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", }; } @@ -536,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}'`, }; } @@ -568,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`, }; } @@ -577,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}`, }; } } @@ -614,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)) { @@ -637,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, + ); } } @@ -648,7 +729,7 @@ async function installFromRepo( repoUrl: string, packageName: string, targetDir: string, - version?: string + version?: string, ): Promise { // Clone the repository const cloneArgs = ["clone"]; @@ -660,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; } @@ -673,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}`, + ); } } @@ -692,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; } @@ -718,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()}`; @@ -733,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}`); @@ -757,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]); @@ -767,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]); @@ -780,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 { @@ -809,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); } @@ -830,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, }); } @@ -855,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 @@ -885,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 @@ -931,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); } @@ -944,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}`); @@ -962,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(() => {}); } @@ -1004,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) { @@ -1016,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`, + ); } // ============================================================================= @@ -1036,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, }; } @@ -1074,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); @@ -1084,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 { @@ -1093,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]); @@ -1104,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`, + ); } // ============================================================================= @@ -1135,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; @@ -1150,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; } @@ -1184,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 @@ -1202,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 @@ -1218,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,6 +1415,33 @@ 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", @@ -1249,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 @@ -1264,7 +1470,7 @@ editor.defineMode( ["S-Tab", "pkg_prev_button"], ["Escape", "pkg_back_or_close"], ], - true // read-only + true, // read-only ); /** @@ -1279,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", @@ -1382,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))) ); } @@ -1427,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) @@ -1470,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); @@ -1478,440 +1692,298 @@ 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; - 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 === - entries.push({ - text: " Packages\n", - properties: { type: "header" }, - }); - - // Empty line after header - entries.push({ text: "\n", properties: { type: "blank" } }); + builder.styled(" Packages\n", themeFg(pkgTheme.header)); + builder.newline(); - // === SEARCH BAR (input-style) === + // === 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); - - 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 }> = [ + 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 filterOptions: FilterOption[] = [ { id: "all", label: "All" }, { id: "installed", label: "Installed" }, { id: "plugins", label: "Plugins" }, { id: "themes", label: "Themes" }, { 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" }); - - 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, - }); - } - - filterBarParts.push({ text: " ", type: "spacer" }); - - // Sync button - always reserve space for brackets + 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 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 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 === - entries.push({ - text: " " + "─".repeat(TOTAL_WIDTH - 2) + "\n", - properties: { type: "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); + + 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), + ); - // Build left panel lines (package list) - const leftLines: Array<{ text: string; type: string; selected?: boolean; installed?: boolean }> = []; + // === HELP LINE === + 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(); - // Installed section - if (installedItems.length > 0) { - leftLines.push({ text: `INSTALLED (${installedItems.length})`, type: "section-title" }); - - 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 }); - idx++; - } - } + // Build and apply to buffer + builder.build(); +} - // Available section - if (availableItems.length > 0) { - if (leftLines.length > 0) leftLines.push({ text: "", type: "blank" }); - leftLines.push({ text: `AVAILABLE (${availableItems.length})`, type: "section-title" }); - - 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 }); - idx++; - } +/** 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}]`; } +} - // Empty state for left panel - if (items.length === 0) { +/** Build the left panel (package list) using GroupedListControl */ +function buildLeftPanel( + installedItems: PackageListItem[], + availableItems: PackageListItem[], + allItems: PackageListItem[], +): PanelLine[] { + // Empty state + if (allItems.length === 0) { if (pkgState.isLoading) { - leftLines.push({ text: "Loading...", type: "empty-state" }); + return [{ text: "Loading...", fg: themeFg(pkgTheme.emptyState) }]; } else if (!isRegistrySynced()) { - leftLines.push({ text: "Registry not synced", type: "empty-state" }); - leftLines.push({ text: "Tab to Sync button", type: "empty-state" }); + return [ + { text: "Registry not synced", fg: themeFg(pkgTheme.emptyState) }, + { text: "Tab to Sync button", fg: themeFg(pkgTheme.emptyState) }, + ]; } else { - leftLines.push({ text: "No packages found", type: "empty-state" }); + return [{ text: "No packages found", fg: themeFg(pkgTheme.emptyState) }]; } } - // Build right panel lines (details for selected package) - const rightLines: Array<{ text: string; type: string; focused?: boolean; btnIndex?: number }> = []; + 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) */ +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: themeFg(pkgTheme.header) }); + lines.push({ + text: "─".repeat( + Math.min(selectedItem.name.length + 2, DETAIL_WIDTH - 2), + ), + fg: themeFg(pkgTheme.separator), + }); - // 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" }); + if (metaLine.length > DETAIL_WIDTH - 2) { + metaLine = metaLine.slice(0, DETAIL_WIDTH - 5) + "..."; + } + lines.push({ text: metaLine, fg: themeFg(pkgTheme.infoLabel) }); - 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" }); + for (const line of wrapText(descText, DETAIL_WIDTH - 2)) { + lines.push({ text: line, fg: themeFg(pkgTheme.description) }); } - 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: ${selectedItem.keywords.slice(0, 4).join(", ")}`, + fg: themeFg(pkgTheme.infoLabel), + }); + lines.push({ text: "" }); } // Repository URL if (selectedItem.repository) { - // Shorten URL for display (remove protocol, truncate if needed) - let displayUrl = selectedItem.repository - .replace(/^https?:\/\//, "") + 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: themeFg(pkgTheme.infoLabel) }); + lines.push({ text: "" }); } - // Action buttons - always reserve space for brackets - const actions = getActionButtons(); - for (let i = 0; i < actions.length; i++) { + // Action buttons + for (let i = 0; i < getActionButtons().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 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 { - 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, - }, - }); - - // 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, - }, - }); - - 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"; + lines.push({ text: "Select a package", fg: themeFg(pkgTheme.emptyState) }); + lines.push({ text: "to view details", fg: themeFg(pkgTheme.emptyState) }); } - helpText += " Esc Close\n"; - - entries.push({ - text: helpText, - properties: { type: "help" }, - }); - return entries; -} - -/** - * 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; - } + return lines; } /** * 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 +2013,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 +2028,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 @@ -2001,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" }, ]; @@ -2038,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(); @@ -2050,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(); @@ -2072,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(); @@ -2082,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(); @@ -2145,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 @@ -2170,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; @@ -2214,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"); - } + }, }); // ============================================================================= @@ -2230,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}`); @@ -2253,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}`); @@ -2265,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) { @@ -2284,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}`); @@ -2296,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; @@ -2322,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]; @@ -2351,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]; @@ -2386,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]; @@ -2433,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); @@ -2452,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(); }; @@ -2491,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. diff --git a/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md b/docs/internal/UNIFIED_UI_FRAMEWORK_PLAN.md index 82c202259..b6c0b8210 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 | ✅ Done | ---