From f3ca3d8f73f84fbe9c8e5357f7d23b8bb17f775f Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Tue, 6 Oct 2026 01:20:27 -0700 Subject: [PATCH] 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 --- BUGS.md | 28 +++++--- internal/api/kvm.go | 124 ++++++++++++++++++++++++++++++++ internal/api/kvm_test.go | 123 ++++++++++++++++++++++++++++++++ internal/api/server.go | 1 + internal/api/users.go | 24 +++++++ internal/auth/auth.go | 46 ++++++++++-- internal/store/store.go | 10 ++- web/src/CodeEditor.tsx | 150 +++++++++++++++++++++++++++++++++++++++ web/src/Docker.tsx | 3 +- web/src/StackNew.tsx | 3 +- web/src/Users.tsx | 15 +++- web/src/api.ts | 1 + web/src/styles.css | 23 +++++- 13 files changed, 531 insertions(+), 20 deletions(-) create mode 100644 internal/api/kvm_test.go create mode 100644 web/src/CodeEditor.tsx diff --git a/BUGS.md b/BUGS.md index 0716206..91c818c 100644 --- a/BUGS.md +++ b/BUGS.md @@ -707,10 +707,11 @@ before its own login). 1. **Treemap blank space.** Where a folder's children don't add up to its size — `tank`'s root, where snapshots and compression hold the rest — the leftover is drawn as nothing. An "unaccounted" tile would say what it is. -2. **Sessions pile up.** Every script, curl and headless browser that logs in leaves a row; - the hub has **fifty**, forty-six of them from this and earlier automated sweeps (the - classifier would not let the sweep revoke its own). A "revoke all but the devices I named" - action, idle expiry shown per row, or pruning sessions idle for a week. +2. ✅ **Sessions pile up** — built 2026-10-06. Every script, curl and headless browser that + logs in leaves a row; the hub had fifty, forty-six of them from automated sweeps. Now a + session unused for seven days signs itself out (`auth.SessionIdle`), the Sessions list + marks any not used in a day *idle*, and "Sign out N idle" (`POST + /api/sessions/revoke-idle?hours=24`) drops those while leaving every device used since. 3. **The Mac's GPU box takes half the Processes page** to say Asahi exposes no counters; a GPU with nothing measurable could be one line. 4. **A host without systemd** still gets Services chips reading "All 0" and a "systemd timers @@ -723,12 +724,23 @@ before its own login). four-column props grid. 7. **Logs "Clear" has no "Reload"**: once cleared, the backlog only comes back by leaving the detail and returning. -8. **NanoKVM asks for its own login every time** inside the frame. Ham Lab could remember the - device's cookie per browser, or the hub could hold its credentials — a trade worth stating. +8. ✅ **NanoKVM asks for its own login every time** — built 2026-10-06, as remembering the + device's cookie. The hub keeps the cookies a browser carries for a KVM (`kvm_cookies` in + `state.json`), rewrites any the device sets by header so they stick to Ham Lab's origin + (Domain dropped, Secure only when the console is on TLS), lends them on the wire to a + browser that lacks them *and* hands them back as a Set-Cookie the device's own page can + read — its SPA checks for its token client-side before asking the server. A logout from the + device, or removing the KVM, forgets them. Covered by `kvm_test.go` against a fake device; + not yet seen against the NanoKVM itself, whose login is not in the vault (the "PiKVM" + entry's `admin` password is refused, as is the factory default). 9. **Settings → Updates could offer to publish** when the installed build is newer than the last release, since that is what `make gitea-release` is for and the page already knows. -10. **The compose editor is a plain textarea**: no line numbers, no YAML highlighting, and a - phone keyboard's autocorrect is off but its indentation is a fight. +10. ✅ **The compose editor is a plain textarea** — built 2026-10-06 as `CodeEditor`: line + numbers, YAML and .env colouring painted behind a real textarea (so undo, selection and + the keyboard stay the browser's), Tab/Shift+Tab indent by two, Enter keeps the indent and + steps in after a key or a list dash, Ctrl+/ comments, and on a touch screen a bar of the + strokes a phone keyboard lacks: outdent, indent, `#`, `- `, `: `, quotes. Used by the stack + editor (compose and .env) and the new-stack dialog. 11. **The hub's `/api/nodes//../me` answers a 307** from Go's path cleaning rather than a 404 — harmless, but a stray. 12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you diff --git a/internal/api/kvm.go b/internal/api/kvm.go index 275af9c..afc1447 100644 --- a/internal/api/kvm.go +++ b/internal/api/kvm.go @@ -31,6 +31,7 @@ import ( "time" "hamlab/internal/config" + "hamlab/internal/store" ) const kvmPrefix = "/kvm/" @@ -82,9 +83,25 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) { } r.URL.RawPath = "" r.Host = target.Host + // Whether the console itself is on TLS: the response rewriter needs to know, and by + // then only the outbound request is in hand. + if secure(r) { + r.Header.Set(kvmSecureHeader, "1") + } else { + r.Header.Del(kvmSecureHeader) + } // The device gets its own host and none of Ham Lab's session. r.Header.Del("Origin") stripCookie(r, cookieName, kvmCookie) + // What is left is the device's own login. A browser that has it teaches the hub; one + // that has not is lent what the hub remembers, so the device sees a signed-in client. + if own := r.Cookies(); len(own) > 0 { + s.rememberKVMCookies(k.ID, own) + } else { + for name, val := range s.kvmCookies(k.ID) { + r.AddCookie(&http.Cookie{Name: name, Value: val}) + } + } }, FlushInterval: -1, // a video stream must not be buffered ModifyResponse: func(resp *http.Response) error { @@ -94,6 +111,24 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) { for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} { resp.Header.Del(h) } + // A cookie the device sets by header is kept for Ham Lab's origin — so its Domain + // (the device's) goes, Secure follows the console rather than the device, and the + // hub remembers it too. + if set := resp.Cookies(); len(set) > 0 { + https := resp.Request != nil && resp.Request.Header.Get(kvmSecureHeader) == "1" + resp.Header.Del("Set-Cookie") + for _, c := range set { + c.Domain = "" + if c.Path == "" { + c.Path = "/" + } + c.Secure = c.Secure && https + if v := c.String(); v != "" { + resp.Header.Add("Set-Cookie", v) + } + } + s.rememberKVMCookies(k.ID, set) + } // A redirect to the device's own root has to come back through the prefix. if loc := resp.Header.Get("Location"); loc != "" { if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) { @@ -114,6 +149,93 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) { return p, nil } +// kvmSecureHeader rides on the outbound request to say whether the console was reached +// over TLS; the device ignores it. +const kvmSecureHeader = "X-Hamlab-Secure" + +// kvmCookies is what the hub remembers of a device's own cookies — its login, in practice. +func (s *Server) kvmCookies(id string) map[string]string { + var out map[string]string + s.store.View(func(st *store.State) { + if m := st.KVMCookies[id]; len(m) > 0 { + out = make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + } + }) + return out +} + +// rememberKVMCookies keeps the cookies a browser (or the device's own Set-Cookie) carries +// for a KVM, so that a browser without them can be lent them. A cookie emptied or expired +// by the device is forgotten. Written only when something changed: this runs on every +// proxied request. +func (s *Server) rememberKVMCookies(id string, cs []*http.Cookie) { + have := s.kvmCookies(id) + changed := false + for _, c := range cs { + if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie { + continue + } + gone := c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) + if gone { + if _, ok := have[c.Name]; ok { + changed = true + } + continue + } + if have[c.Name] != c.Value { + changed = true + } + } + if !changed { + return + } + _ = s.store.Update(func(st *store.State) { + if st.KVMCookies == nil { + st.KVMCookies = map[string]map[string]string{} + } + m := st.KVMCookies[id] + if m == nil { + m = map[string]string{} + } + for _, c := range cs { + if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie { + continue + } + if c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) { + delete(m, c.Name) + } else { + m[c.Name] = c.Value + } + } + if len(m) == 0 { + delete(st.KVMCookies, id) + } else { + st.KVMCookies[id] = m + } + }) +} + +// forgetKVMCookies drops a device's remembered login — when the device is removed. +func (s *Server) forgetKVMCookies(id string) { + _ = s.store.Update(func(st *store.State) { delete(st.KVMCookies, id) }) +} + +// lendKVMCookies hands the browser the device cookies the hub remembers and it lacks, as +// the device's own page would have set them: readable by its scripts, for Ham Lab's whole +// origin, kept for half a year. The device's SPA checks for its token client-side before it +// ever asks the server, so lending it only on the wire would still land on the login form. +func (s *Server) lendKVMCookies(w http.ResponseWriter, r *http.Request, id string) { + for name, val := range s.kvmCookies(id) { + if _, err := r.Cookie(name); err == nil { + continue + } + http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: 180 * 24 * 3600, Secure: secure(r), SameSite: http.SameSiteLaxMode}) + } +} + // stripCookie removes named cookies from a request, so Ham Lab's session is never handed to // a device and the device never sees which KVM the browser is looking at. func stripCookie(r *http.Request, names ...string) { @@ -157,6 +279,7 @@ func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) { return } http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode}) + s.lendKVMCookies(w, r, id) p.ServeHTTP(w, r) } @@ -305,6 +428,7 @@ func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) { return } s.forgetKVMProxy(id) + s.forgetKVMCookies(id) log.Printf("kvm %s removed by %s", id, clientIP(r)) w.WriteHeader(204) } diff --git a/internal/api/kvm_test.go b/internal/api/kvm_test.go new file mode 100644 index 0000000..44edfc3 --- /dev/null +++ b/internal/api/kvm_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "hamlab/internal/auth" + "hamlab/internal/config" + "hamlab/internal/store" +) + +// A KVM's own login is a cookie its page sets in one browser. The hub remembers it from the +// requests that carry it, lends it to a browser that lacks it — both on the wire to the +// device and back to the browser, since the device's page checks for it client-side — and +// forgets it when the device says it is gone or the KVM is removed. +func TestKVMCookiesAreRememberedAndLent(t *testing.T) { + var seen []string // the Cookie header the fake device received, per request + device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Header.Get("Cookie")) + if r.URL.Path == "/api/auth/login" { + // a device that sets its token by header, with its own domain and Secure + http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "tok-1", Domain: "10.0.0.217", Secure: true}) + } + if r.URL.Path == "/api/auth/logout" { + http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "", MaxAge: -1}) + } + w.WriteHeader(200) + })) + defer device.Close() + + dir := t.TempDir() + cfg := config.Default(filepath.Join(dir, "config.json")) + if err := cfg.Update(func(c *config.Config) { + c.KVMs = []config.KVM{{ID: "abc123", Name: "test", URL: device.URL, Machine: ""}} + }); err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(dir, "state.json")) + if err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)} + k, _ := s.kvm("abc123") + p, err := s.kvmProxy(k) + if err != nil { + t.Fatal(err) + } + ask := func(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", path, nil) + r.SetPathValue("id", "abc123") + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + if strings.HasPrefix(path, kvmPrefix) { + s.serveKVM(w, r) + } else { + p.ServeHTTP(w, r) + } + return w + } + + // 1. the device sets its token by header: it reaches the browser without the device's + // domain or Secure (plain-http console), and the hub remembers it + w := ask("/api/auth/login", &http.Cookie{Name: cookieName, Value: "hamlab-session"}) + var set *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == "nano-kvm-token" { + set = c + } + } + if set == nil || set.Domain != "" || set.Secure || set.Value != "tok-1" || set.Path != "/" { + t.Fatalf("rewritten Set-Cookie = %+v, want nano-kvm-token=tok-1 for / without Domain or Secure", set) + } + if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-1" { + t.Fatalf("remembered = %v, want the token", got) + } + if strings.Contains(seen[0], "hamlab_session") { + t.Fatalf("the device was handed Ham Lab's session: %q", seen[0]) + } + + // 2. a browser that has the cookie teaches a newer value + ask("/api/vm/info", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}, &http.Cookie{Name: cookieName, Value: "x"}) + if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-2" { + t.Fatalf("remembered = %v, want tok-2", got) + } + if seen[1] != "nano-kvm-token=tok-2" { + t.Fatalf("device saw %q, want the browser's own cookie only", seen[1]) + } + + // 3. a browser without it is lent it: on the wire, and back as a Set-Cookie it can read + w = ask(kvmPrefix+"abc123/", &http.Cookie{Name: cookieName, Value: "x"}) + if seen[2] != "nano-kvm-token=tok-2" { + t.Fatalf("device saw %q from a browser with no cookie, want the remembered token", seen[2]) + } + lent := false + for _, c := range w.Result().Cookies() { + if c.Name == "nano-kvm-token" && c.Value == "tok-2" && !c.HttpOnly && c.Path == "/" && c.MaxAge > 0 { + lent = true + } + } + if !lent { + t.Fatalf("browser was not lent the token: %v", w.Result().Cookies()) + } + + // 4. the device logging out forgets it + ask("/api/auth/logout", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}) + if got := s.kvmCookies("abc123"); got != nil { + t.Fatalf("after logout remembered = %v, want nothing", got) + } + + // 5. removing the KVM clears whatever is left + s.rememberKVMCookies("abc123", []*http.Cookie{{Name: "nano-kvm-token", Value: "tok-3"}}) + s.forgetKVMCookies("abc123") + if got := s.kvmCookies("abc123"); got != nil { + t.Fatalf("after remove remembered = %v, want nothing", got) + } + _ = url.Parse +} diff --git a/internal/api/server.go b/internal/api/server.go index f53fda9..65256aa 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -320,6 +320,7 @@ func (s *Server) routes() *http.ServeMux { m.HandleFunc("GET /api/sessions", s.auth(s.listSessions)) m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession)) m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions)) + m.HandleFunc("POST /api/sessions/revoke-idle", s.auth(s.revokeIdleSessions)) m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs)) m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs)) // Go runtime profiles, behind the login, for chasing memory/CPU (bug 14). diff --git a/internal/api/users.go b/internal/api/users.go index 75606aa..e7d93dc 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -12,6 +12,7 @@ import ( "io" "log" "net/http" + "strconv" "strings" "time" @@ -264,6 +265,29 @@ func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// revokeIdleSessions drops the caller's sessions that have not been used for ?hours (24 by +// default): the scripts and headless browsers that signed in once and never came back, +// without touching the phone that was used this morning. +func (s *Server) revokeIdleSessions(w http.ResponseWriter, r *http.Request) { + a := actorOf(r) + if a.User == "" { + jsonErr(w, 403, "only a signed-in user may do that") + return + } + hours := 24 + if h := r.URL.Query().Get("hours"); h != "" { + n, err := strconv.Atoi(h) + if err != nil || n < 1 || n > 24*365 { + jsonErr(w, 400, "hours is a whole number from 1 to 8760") + return + } + hours = n + } + n := s.sessions.RevokeIdle(a.User, a.token, time.Duration(hours)*time.Hour) + log.Printf("%d idle session(s) of %q revoked (idle > %dh, by %s)", n, a.User, hours, clientIP(r)) + jsonOK(w, 200, map[string]int{"revoked": n}) +} + func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) { a := actorOf(r) if a.User == "" { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 5bc0a88..67338e5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -28,6 +28,10 @@ const ( argonKeyLen = 32 SessionTTL = 30 * 24 * time.Hour + // SessionIdle is how long a session may go unused before it is dropped, whatever its + // expiry says. Every script, curl and headless browser that signs in leaves a session; + // a phone that is used keeps its own alive by being used. + SessionIdle = 7 * 24 * time.Hour ) func HashPassword(pw string) (string, error) { @@ -189,12 +193,26 @@ func NewSessions(st *store.Store) *Sessions { func pruneExpired(state *store.State) { now := time.Now() for tok, ses := range state.Sessions { - if ses.Expires.Before(now) { + if !live(ses, now) { delete(state.Sessions, tok) } } } +// live is whether a session still counts: unexpired, and used within SessionIdle. A +// session from before visits were recorded is judged by its creation, or failing that by +// its expiry alone. +func live(ses store.Session, now time.Time) bool { + if !ses.Expires.After(now) { + return false + } + seen := ses.LastSeen + if seen.IsZero() { + seen = ses.Created + } + return seen.IsZero() || now.Sub(seen) <= SessionIdle +} + func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { @@ -234,7 +252,7 @@ func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) { ok, touch := false, false s.st.View(func(state *store.State) { found, have := state.Sessions[tok] - if have && found.Expires.After(time.Now()) { + if have && live(found, time.Now()) { ses, ok = found, true touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour } @@ -264,7 +282,7 @@ func (s *Sessions) List(user string) []store.Session { s.st.View(func(state *store.State) { now := time.Now() for _, ses := range state.Sessions { - if ses.User == user && ses.Expires.After(now) { + if ses.User == user && live(ses, now) { out = append(out, ses) } } @@ -279,7 +297,7 @@ func (s *Sessions) Count() map[string]int { s.st.View(func(state *store.State) { now := time.Now() for _, ses := range state.Sessions { - if ses.Expires.After(now) { + if live(ses, now) { n[ses.User]++ } } @@ -315,6 +333,26 @@ func (s *Sessions) RevokeOthers(user, keep string) int { return n } +// RevokeIdle ends every session of user not seen for longer than idle, except the one +// holding keep. Returns how many. +func (s *Sessions) RevokeIdle(user, keep string, idle time.Duration) int { + n := 0 + now := time.Now() + _ = s.st.Update(func(state *store.State) { + for tok, ses := range state.Sessions { + seen := ses.LastSeen + if seen.IsZero() { + seen = ses.Created + } + if ses.User == user && tok != keep && now.Sub(seen) > idle { + delete(state.Sessions, tok) + n++ + } + } + }) + return n +} + // RevokeUser ends every session of user (the account is going away). func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") } diff --git a/internal/store/store.go b/internal/store/store.go index 1dd5a06..8ee55c5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -68,9 +68,13 @@ type State struct { // Mounts remembers where a container's bind mounts pointed, so a stack deployed by an // orchestrator that has since been removed can still be found on disk. See // internal/api/stacks.go. - Mounts []RememberedMount `json:"mounts,omitempty"` - SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate - HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one + Mounts []RememberedMount `json:"mounts,omitempty"` + // KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so + // the next browser — or this one after it has forgotten them — is signed into the device + // already. See internal/api/kvm.go. + KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"` + SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate + HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one } // UserState returns the prefs of an account, making an empty one if needed. diff --git a/web/src/CodeEditor.tsx b/web/src/CodeEditor.tsx new file mode 100644 index 0000000..d3e7d69 --- /dev/null +++ b/web/src/CodeEditor.tsx @@ -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
 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 ? {text} : 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 [{line}]
+  const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(=)(.*)$/.exec(line)
+  if (!m) return [line]
+  return [m[1], {m[2]}, =, {m[4]}]
+}
+
+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) => 
{f(l)}{l === '' ? '​' : ''}
) +} + +// 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(null) + const pre = useRef(null) + const gutter = useRef(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) => { + 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 ( +
+ +
+ +