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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
faaa6aaf9d
commit
f3ca3d8f73
@@ -0,0 +1,150 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user