/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import { defaultKeymap, history as historyExtension, historyKeymap, indentWithTab } from '@codemirror/commands'; import { foldGutter as foldGutterExtension, foldKeymap, indentOnInput as indentOnInputExtension, LanguageDescription, LanguageSupport } from '@codemirror/language'; import {languages} from '@codemirror/language-data'; import {linter as linterExtension, lintGutter as lintGutterExtension} from '@codemirror/lint'; import { Compartment, EditorState, Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state'; import { Decoration, DecorationSet, EditorView, highlightActiveLine as highlightActiveLineExtension, highlightActiveLineGutter as highlightActiveLineGutterExtension, keymap, lineNumbers as lineNumbersExtension, ViewPlugin, ViewUpdate } from '@codemirror/view'; import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {box, div, filler, fragment, frame, hbox, label, span, vbox} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, LayoutProps, managed, PlainObject, XH} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import {clipboardButton} from '@xh/hoist/desktop/cmp/clipboard'; import {textInput} from '@xh/hoist/desktop/cmp/input/TextInput'; import {modalSupport} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupport'; import {ModalSupportModel} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupportModel'; import {toolbar} from '@xh/hoist/desktop/cmp/toolbar'; import {Icon} from '@xh/hoist/icon'; import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx'; import {logError, logWarn, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import {compact, find, includes, isFunction, isNil, isObject} from 'lodash'; import {ReactElement} from 'react'; import './CodeInput.scss'; import {githubLight, githubDark} from '@uiw/codemirror-theme-github'; export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps { /** True to focus the control on render. */ autoFocus?: boolean; /** * True to automatically format content for display using the configured `formatter`. * Defaults to true for `readonly` inputs - set false to opt out. May also be enabled on * editable inputs, in which case content is formatted on blur (never mid-edit, so user * edits and cursor position are preserved while typing). */ autoFormat?: boolean; /** False to not commit on every change/keystroke, default true. */ commitOnChange?: boolean; /** * True to enable case-insensitive searching within the input. Default false, except in * fullscreen mode, where search will be shown unless explicitly *disabled*. Note that * enabling search forces the display of a toolbar, regardless of `showToolbar` prop. */ enableSearch?: boolean; /** * Callback to autoformat the code. Given the unformatted code, this should return a * properly-formatted copy. */ formatter?: (str: string) => string; /** True to highlight active line in input. (Default false) */ highlightActiveLine?: boolean; /** * A CodeMirror language mode - default none (plain-text). See the CodeMirror docs * ({@link https://github.com/codemirror/language-data/blob/main/src/language-data.ts}) regarding available languages. * String can be the alias or name (E.G. `JSON`, `JavaScript`, `js`, `sql`, `XML`, ect.) */ language?: string; /** * True (default) to add line numbers to the gutter. * If a PlainObject is provided, it will be passed to the CM6 lineNumbers extension. * See CodeMirror 6 docs: https://codemirror.net/6/docs/ref/#gutter.lineNumbers */ lineNumbers?: boolean | PlainObject; /** * True to enable line wrapping. (Default false) */ lineWrapping?: boolean; /** * A CodeMirror linter to provide error detection and hinting in the gutter. */ linter?: (text: string) => any[]; /** * True to prevent user modification of editor contents, while still allowing user to * focus, select, and copy contents. */ readonly?: boolean; /** True (default) to display a copy button at bottom-right of input. */ showCopyButton?: boolean; /** * True (default) to display autoformat button at bottom-right of input. Requires a * `formatter` to be configured and content to be editable (!readonly, !disabled). */ showFormatButton?: boolean; /** True (default) to display fullscreen button at bottom-right of input. */ showFullscreenButton?: boolean; /** * True to display action buttons and/or find functionality in a dedicated bottom toolbar. * Default is false unless enableSearch==true or in fullscreen mode. When false, enabled * action buttons show only when the input focused and float in the bottom-right corner. */ showToolbar?: boolean; } /** * Code-editor style input, powered by CodeMirror. Displays a gutter with line numbers, mono-spaced * styling, and custom key handlers (e.g. tab to indent). Can be customized with options and * language modes supported by the underlying CodeMirror library {@link https://codemirror.net/}. * * Note Hoist also provides a preconfigured {@link JsonInput} component for editing JSON. * * TODO - understanding sizing spec / requirements for component vs. generated CodeMirror. * Reconcile LayoutSupport with width/height props. https://github.com/xh/hoist-react/issues/327 */ export const [CodeInput, codeInput] = hoistCmp.withFactory({ displayName: 'CodeInput', className: 'xh-code-input', render(props, ref) { return useHoistInputModel(cmp, props, ref, CodeInputModel); } }); (CodeInput as any).hasLayoutSupport = true; //------------------------------ // Implementation //------------------------------ class CodeInputModel extends HoistInputModel { override xhImpl = true; @managed modalSupportModel: ModalSupportModel = new ModalSupportModel(); @managed editor: EditorView; // Support for internal search feature. @bindable query: string = ''; @observable currentMatchIdx: number = -1; @observable.ref matches: {from: number; to: number}[] = []; private updateMatchesEffect = StateEffect.define(); private themeCompartment = new Compartment(); private editableCompartment = new Compartment(); get fullScreen(): boolean { return this.modalSupportModel.isModal; } get showCopyButton(): boolean { return withDefault(this.componentProps.showCopyButton, true); } get showFullscreenButton(): boolean { return withDefault(this.componentProps.showFullscreenButton, true); } get showFormatButton(): boolean { const {disabled, readonly, formatter, showFormatButton} = this.componentProps; return ( !disabled && !readonly && isFunction(formatter) && withDefault(showFormatButton, true) ); } get showAnyActionButtons(): boolean { const {showCopyButton, showFormatButton, showFullscreenButton} = this; return showCopyButton || showFormatButton || showFullscreenButton; } get showSearchInput(): boolean { return withDefault(this.componentProps.enableSearch, this.fullScreen); } get showToolbar(): boolean { const {componentProps, showSearchInput, showAnyActionButtons, fullScreen} = this; // Always show if showing searchInput - it's the only place searchInput can live. if (showSearchInput) return true; // Show if prop enabled and at least one action button. if (componentProps.showToolbar && showAnyActionButtons) return true; // Show if fullscreen w/buttons and prop not explicitly *disabled*. return fullScreen && showAnyActionButtons && componentProps.showToolbar !== false; } get actionButtons(): ReactElement[] { const {showCopyButton, showFormatButton, showFullscreenButton} = this; return compact([ showCopyButton ? clipboardButton({ text: null, title: 'Copy to clipboard', successMessage: 'Contents copied to clipboard', getCopyText: () => this.internalValue }) : null, showFormatButton ? button({ icon: Icon.magic(), title: 'Auto-format', onClick: () => this.formatAndSetEditorValue() }) : null, showFullscreenButton ? button({ icon: this.fullScreen ? Icon.collapse() : Icon.expand(), title: this.fullScreen ? 'Exit full screen' : 'Full screen', onClick: () => this.toggleFullScreen() }) : null ]); } override get commitOnChange(): boolean { return withDefault(this.componentProps.commitOnChange, true); } override blur() { this.editor?.contentDOM.blur(); } override focus() { this.editor?.focus(); } override select() { this.editor?.dispatch({selection: {anchor: 0, head: this.editor.state.doc.length}}); } constructor() { super(); makeObservable(this); this.addReaction({ track: () => this.modalSupportModel.isModal, run: () => this.focus(), debounce: 1 }); } override onLinked() { this.addReaction( { track: () => XH.darkTheme, run: () => { const {editor} = this; if (editor) { editor.dispatch({ effects: this.themeCompartment.reconfigure(this.getTheme()) }); } } }, { track: () => this.renderValue, run: val => { const {editor} = this; if (editor && editor.state.doc.toString() !== val) { editor.dispatch({ changes: {from: 0, to: editor.state.doc.length, insert: val ?? ''} }); } } }, { track: () => this.componentProps.readonly || this.componentProps.disabled, run: readOnly => { const {editor} = this; if (editor) editor.dispatch({ effects: this.editableCompartment.reconfigure( EditorView.editable.of(!readOnly) ) }); } }, { track: () => this.query, run: query => { if (query?.trim()) { this.findAll(); } else { this.clearSearchResults(); } }, debounce: 300 } ); } createCodeEditor = async (container: HTMLElement) => { if (!container) return; const extensions = await this.getExtensionsAsync(); const state = EditorState.create({doc: this.renderValue || '', extensions}); this.editor = new EditorView({state, parent: container}); }; get autoFormat(): boolean { const {autoFormat, readonly} = this.componentProps; return withDefault(autoFormat, !!readonly); } override toInternal(val: any) { return this.autoFormat ? this.tryPrettyPrint(val) : val; } private formatAndSetEditorValue() { if (!this.editor) return; const val = this.tryPrettyPrint(this.editor.state.doc.toString()); this.editor.dispatch({changes: {from: 0, to: this.editor.state.doc.length, insert: val}}); } toggleFullScreen() { this.modalSupportModel.toggleIsModal(); } //------------------------ // Local Searching //------------------------ @action findAll() { const {query, editor} = this; if (!editor || !query?.trim()) return; // Case-insensitive: match against a lowercased copy of the doc and query. // Match offsets remain valid against the original doc (ASCII-length-preserving). let doc = editor.state.doc.toString().toLowerCase(), q = query.toLowerCase(), matches = [], idx = doc.indexOf(q); while (idx !== -1) { matches.push({from: idx, to: idx + q.length}); idx = doc.indexOf(q, idx + 1); } this.matches = matches; this.currentMatchIdx = -1; this.findNext(); } findNext() { this.navigateMatch(true); } findPrevious() { this.navigateMatch(false); } @action private navigateMatch(forward: boolean) { const {editor, matches} = this; if (!editor || !matches.length) return; this.currentMatchIdx = (this.currentMatchIdx + (forward ? 1 : -1) + matches.length) % matches.length; const match = matches[this.currentMatchIdx]; this.updateMatchDecorations(); editor.dispatch({ selection: {anchor: match.from, head: match.to}, scrollIntoView: true }); } @action updateMatchDecorations() { this.editor?.dispatch({effects: this.updateMatchesEffect.of()}); } @action clearSearchResults() { this.matches = []; this.currentMatchIdx = -1; this.updateMatchDecorations(); } //------------------------ // Implementation //------------------------ private async getExtensionsAsync(): Promise { const { autoFocus, disabled, readonly, language, highlightActiveLine, linter, lineNumbers = true, lineWrapping = false } = this.componentProps, extensions = [ // Switches between dark/light theme using GitHub theme presets. this.getThemeExtension(), // Makes the editor read-only if `readonly` or `disabled` is true. // Kept in sync with later prop changes by a reaction in onLinked(). this.editableCompartment.of(EditorView.editable.of(!readonly && !disabled)), // Listens for changes in the document. // - Calls `noteValueChange` to update the Hoist input model. // - Clears custom search results when document changes. EditorView.updateListener.of((update: ViewUpdate) => { if (update.docChanged) { this.noteValueChange(update.state.doc.toString()); this.clearSearchResults(); } }), // Custom search highlight this.getStateFieldHighlightExtension(), // Auto-indent on enter indentOnInputExtension(), // Provides undo/redo (Ctrl+Z / Ctrl+Shift+Z) historyExtension(), // If a linter function is provided, this shows gutter hints and inline messages. linter ? linterExtension(view => linter(view.state.doc.toString())) : null, // ----------------------------- // Key bindings - standard CodeMirror keymaps plus custom tab and auto format support. // ----------------------------- keymap.of([ ...defaultKeymap, ...historyKeymap, ...foldKeymap, indentWithTab, { key: 'Mod-p', run: () => { this.formatAndSetEditorValue(); return true; } } ]) ]; if (lineWrapping) { extensions.push(EditorView.lineWrapping); } if (highlightActiveLine) { extensions.push(highlightActiveLineExtension(), highlightActiveLineGutterExtension()); } if (autoFocus) { extensions.push(this.autofocusExtension); } if (language) { const langExt = await this.getLanguageExtensionAsync(language); if (langExt) { extensions.push(langExt); } else { logWarn( `Language "${language}" is not recognized. ` + `See the list of supported languages and aliases: ` + `https://github.com/codemirror/language-data/blob/main/src/language-data.ts` ); } } // Gutters are rendered in the editor in the order they are added to the extensions array. // The order determines their left-to-right placement in the UI. if (lineNumbers) { extensions.push( isObject(lineNumbers) ? lineNumbersExtension(lineNumbers) : lineNumbersExtension() ); } extensions.push(foldGutterExtension()); extensions.push(lintGutterExtension()); return extensions.filter(it => !isNil(it)); } private getThemeExtension() { return this.themeCompartment.of(this.getTheme()); } private getTheme() { return XH.darkTheme ? githubDark : githubLight; } private async getLanguageExtensionAsync(lang: string): Promise { const langDesc: LanguageDescription = find( languages, it => includes(it.alias, lang) || it.name.toLowerCase() === lang.toLowerCase() ); if (!langDesc) return null; try { // Attempt to dynamically import the language module return await langDesc.load(); } catch (err) { logError( `Failed to dynamically load CodeMirror language module for "${langDesc.name}":`, err ); return null; } } /** * Tracks the current search matches (`this.matches`) and highlights them in the editor. * Rebuilds the highlight decorations whenever `updateMatchesEffect` is dispatched. * Provides these highlights to CodeMirror as a DecorationSet applied via EditorView.decorations. */ private getStateFieldHighlightExtension() { return StateField.define({ create: () => Decoration.none, update: (deco, tr) => { deco = deco.map(tr.changes); if (tr.effects.some(e => e.is(this.updateMatchesEffect))) { const builder = new RangeSetBuilder(); this.matches.forEach((match, idx) => { const isActive = idx === this.currentMatchIdx; builder.add( match.from, match.to, Decoration.mark({ class: isActive ? 'xh-code-input--highlight-active' : 'xh-code-input--highlight' }) ); }); deco = builder.finish(); } return deco; }, provide: f => EditorView.decorations.from(f) }); } private autofocusExtension = ViewPlugin.fromClass( class { constructor(view: EditorView) { queueMicrotask(() => view.focus()); } } ); private tryPrettyPrint(str: string) { try { return this.componentProps.formatter?.(str) ?? str; } catch (e) { return str; } } } const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { return box({ className: 'xh-code-input__outer-wrapper', width: 300, height: 100, ...getLayoutProps(props), item: modalSupport({ model: model.modalSupportModel, item: inputCmp({ testId: props.testId, width: '100%', height: '100%', className, ref, model }) }) }); }); const inputCmp = hoistCmp.factory(({model, ...props}, ref) => vbox({ items: [ div({ className: 'xh-code-input__inner-wrapper', // We pass the container via ref to createCodeEditor, which initializes the editor inside it. ref: model.createCodeEditor }), model.showToolbar ? toolbarCmp() : actionButtonsCmp() ], onBlur: model.onBlur, onFocus: model.onFocus, ...props, ref }) ); const toolbarCmp = hoistCmp.factory(({model}) => { const {actionButtons, showSearchInput, fullScreen} = model; return toolbar({ className: 'xh-code-input__toolbar', compact: !fullScreen, items: [searchInputCmp({omit: !showSearchInput}), filler(), ...actionButtons] }); }); const searchInputCmp = hoistCmp.factory(({model}) => { const {query, currentMatchIdx, matches, fullScreen} = model, matchCount = matches.length; return fragment( // Frame wrapper added due to issues with textInput not supporting all layout props as it should. frame({ flex: 1, maxWidth: !fullScreen ? 225 : 400, item: textInput({ width: null, flex: 1, model, bind: 'query', leftIcon: Icon.search(), enableClear: true, commitOnChange: true, onKeyDown: e => { if (e.key !== 'Enter') return; if (!matchCount) { model.findAll(); } else if (e.shiftKey) { model.findPrevious(); } else { model.findNext(); } } }) }), label({ className: classNames('xh-code-input__label', !fullScreen ? 'xh-no-pad' : null), item: matchCount ? `${currentMatchIdx + 1} / ${matchCount}` : span({item: '0 results', className: 'xh-text-color-muted'}), omit: !query }), button({ icon: Icon.arrowUp(), title: 'Find previous (shift+enter)', className: !fullScreen ? 'xh-no-pad' : null, disabled: !matchCount, onClick: () => model.findPrevious(), omit: !query }), button({ icon: Icon.arrowDown(), title: 'Find next (enter)', className: !fullScreen ? 'xh-no-pad' : null, disabled: !matchCount, onClick: () => model.findNext(), omit: !query }) ); }); const actionButtonsCmp = hoistCmp.factory(({model}) => { const {hasFocus, actionButtons} = model; return hasFocus && actionButtons.length ? hbox({ className: 'xh-code-input__action-buttons', items: actionButtons }) : null; });