Files
hamlab/web/src/CodeEditor.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 f3ca3d8f73 Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML
A session unused for seven days is dropped, the Sessions list marks a day's idleness and
can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that
has them and lends them to one that hasn't, on the wire and back to the page. The stack
editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a
real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 01:20:27 -07:00

151 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A textarea that knows what it is holding: line numbers in a gutter, YAML (or a .env)
// coloured underneath the text, and the keys a compose file needs — Tab indents by two,
// Shift+Tab outdents, Enter keeps the indentation of the line above (one step deeper after
// a key or a list dash), and a phone, whose keyboard has none of those, gets a small bar of
// them. The text itself stays in a real textarea, so selection, undo and the keyboard are
// the browser's own; the colour is a <pre> drawn behind it, kept in step line for line.
import { useEffect, useMemo, useRef, useState, ReactNode } from 'react'
export type Language = 'yaml' | 'env' | 'plain'
interface Props {
value: string; onChange: (v: string) => void; language?: Language; className?: string
readOnly?: boolean; placeholder?: string; spellCheck?: boolean
}
const INDENT = ' '
const coarse = () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
// One line of YAML, split into spans by what each part is. Deliberately shallow: a compose
// file is keys, scalars, lists and comments; anything cleverer is a parser, which this is not.
function yamlLine(line: string): ReactNode[] {
const out: ReactNode[] = []
let i = 0
const push = (cls: string, text: string) => { if (text) out.push(cls ? <span key={out.length} className={cls}>{text}</span> : text) }
const m = /^(\s*)(-\s+)?/.exec(line)!
push('', m[1]); push('y-dash', m[2] ?? ''); i = m[0].length
const rest = line.slice(i)
if (rest.startsWith('#')) { push('y-comment', rest); return out }
// a key: up to the first colon that is followed by a space or the end
const key = /^("[^"]*"|'[^']*'|[^\s:#][^:#]*?)(:)(?=\s|$)/.exec(rest)
let tail = rest
if (key) { push('y-key', key[1]); push('y-colon', ':'); tail = rest.slice(key[0].length) }
// the value, with a trailing comment cut off
let value = tail, comment = ''
const c = / #.*$/.exec(tail) ?? (tail.startsWith('#') ? /#.*$/.exec(tail) : null)
if (c) { value = tail.slice(0, c.index); comment = tail.slice(c.index) }
const v = value.trim()
const lead = value.slice(0, value.length - value.trimStart().length)
push('', lead)
if (!v) { /* nothing */ }
else if (/^(["']).*\1$/.test(v)) push('y-string', v)
else if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) push('y-bool', v)
else if (/^[-+]?(\d[\d_]*(\.\d+)?|\.\d+)([eE][-+]?\d+)?$/.test(v) || /^0x[0-9a-fA-F]+$/.test(v)) push('y-number', v)
else if (/^[&*!]/.test(v)) push('y-anchor', v)
else if (/^[|>][-+]?\d*$/.test(v)) push('y-block', v)
else push(key ? 'y-value' : (m[2] ? 'y-value' : ''), v)
push('', value.slice(lead.length + v.length))
push('y-comment', comment)
return out
}
function envLine(line: string): ReactNode[] {
if (/^\s*#/.test(line)) return [<span key="c" className="y-comment">{line}</span>]
const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(=)(.*)$/.exec(line)
if (!m) return [line]
return [m[1], <span key="k" className="y-key">{m[2]}</span>, <span key="e" className="y-colon">=</span>, <span key="v" className="y-string">{m[4]}</span>]
}
function highlight(text: string, lang: Language): ReactNode[] {
const lines = text.split('\n')
const f = lang === 'yaml' ? yamlLine : lang === 'env' ? envLine : (l: string) => [l] as ReactNode[]
return lines.map((l, i) => <div key={i} className="code-line">{f(l)}{l === '' ? '​' : ''}</div>)
}
// setText replaces [start, end) in a textarea with s, through the browser's own edit so Undo
// still works, and leaves the caret where asked.
function setText(ta: HTMLTextAreaElement, start: number, end: number, s: string, caret: number | [number, number]) {
ta.focus()
ta.setSelectionRange(start, end)
let done = false
try { done = document.execCommand('insertText', false, s) } catch { /* not every browser */ }
if (!done || ta.value.slice(start, start + s.length) !== s) { ta.setRangeText(s, start, end, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })) }
if (Array.isArray(caret)) ta.setSelectionRange(caret[0], caret[1]); else ta.setSelectionRange(caret, caret)
}
export default function CodeEditor({ value, onChange, language = 'yaml', className = '', readOnly, placeholder, spellCheck = false }: Props) {
const ta = useRef<HTMLTextAreaElement>(null)
const pre = useRef<HTMLPreElement>(null)
const gutter = useRef<HTMLDivElement>(null)
const [touch] = useState(coarse)
const lines = useMemo(() => value.split('\n').length, [value])
const painted = useMemo(() => highlight(value, language), [value, language])
// the colour and the numbers follow the text's own scrolling
const sync = () => { const t = ta.current; if (!t) return; if (pre.current) { pre.current.scrollTop = t.scrollTop; pre.current.scrollLeft = t.scrollLeft } if (gutter.current) gutter.current.scrollTop = t.scrollTop }
useEffect(sync, [value])
// lines touched by the selection, for indent and outdent
const lineRange = (t: HTMLTextAreaElement) => {
const s = t.value.lastIndexOf('\n', t.selectionStart - 1) + 1
let e = t.value.indexOf('\n', Math.max(t.selectionEnd - (t.selectionEnd > t.selectionStart && t.value[t.selectionEnd - 1] === '\n' ? 1 : 0), t.selectionStart))
if (e < 0) e = t.value.length
return [s, e] as const
}
const indent = (out: boolean) => {
const t = ta.current; if (!t || readOnly) return
const [s, e] = lineRange(t)
const block = t.value.slice(s, e)
const sel = t.selectionStart !== t.selectionEnd || block.includes('\n')
if (!sel && !out) { const at = t.selectionStart; setText(t, at, at, INDENT, at + INDENT.length); return }
const next = block.split('\n').map((l) => (out ? l.replace(/^ {1,2}/, '') : INDENT + l)).join('\n')
if (next === block) return
const d0 = out ? -(block.length - next.length) : INDENT.length
setText(t, s, e, next, sel ? [s, s + next.length] : [Math.max(s, t.selectionStart + d0), Math.max(s, t.selectionStart + d0)])
}
const comment = () => {
const t = ta.current; if (!t || readOnly) return
const [s, e] = lineRange(t)
const block = t.value.slice(s, e), ls = block.split('\n')
const all = ls.every((l) => /^\s*#/.test(l) || !l.trim())
const next = ls.map((l) => (!l.trim() ? l : all ? l.replace(/^(\s*)# ?/, '$1') : l.replace(/^(\s*)/, '$1# '))).join('\n')
setText(t, s, e, next, [s, s + next.length])
}
const newline = () => {
const t = ta.current; if (!t || readOnly) return
const at = t.selectionStart
const lineStart = t.value.lastIndexOf('\n', at - 1) + 1
const line = t.value.slice(lineStart, at)
let ind = /^\s*/.exec(line)![0]
// one step deeper after a key or a list item that opens a block
if (language === 'yaml' && /(:|^\s*-)\s*(#.*)?$/.test(line) && line.trim() && !/^\s*#/.test(line)) ind += INDENT
setText(t, at, t.selectionEnd, '\n' + ind, at + 1 + ind.length)
}
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); indent(e.shiftKey) }
else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); newline() }
else if (e.key === '/' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); comment() }
}
return (
<div className={'code ' + className + (readOnly ? ' ro' : '')}>
<div className="code-gutter" ref={gutter} aria-hidden="true">{Array.from({ length: lines }, (_, i) => <div key={i}>{i + 1}</div>)}</div>
<div className="code-body">
<pre className="code-paint" ref={pre} aria-hidden="true">{painted}</pre>
<textarea ref={ta} className="code-text" value={value} onChange={(e) => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey}
readOnly={readOnly} placeholder={placeholder} spellCheck={spellCheck} autoCapitalize="off" autoCorrect="off" autoComplete="off" wrap="off" />
</div>
{touch && !readOnly && (
<div className="code-keys" onMouseDown={(e) => e.preventDefault()}>
<button type="button" title="outdent" onClick={() => indent(true)}>⇤</button>
<button type="button" title="indent" onClick={() => indent(false)}>⇥</button>
<button type="button" title="comment" onClick={comment}>#</button>
<button type="button" title="list item" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '- ', t.selectionStart + 2) }}>-</button>
<button type="button" title="colon" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, ': ', t.selectionStart + 2) }}>:</button>
<button type="button" title="quotes" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '"' + t.value.slice(t.selectionStart, t.selectionEnd) + '"', t.selectionEnd + 1) }}>"</button>
</div>
)}
</div>
)
}