diff options
| -rw-r--r-- | doc.txt | 4 | ||||
| -rw-r--r-- | main.go | 53 | ||||
| -rw-r--r-- | main_test.go | 38 | ||||
| -rw-r--r-- | query-doc.txt | 2 | ||||
| -rw-r--r-- | vr-log.jsonl | 66 | ||||
| -rw-r--r-- | vrsite/app_test.go | 98 | ||||
| -rw-r--r-- | vrsite/assets/app.js | 212 | ||||
| -rw-r--r-- | vrsite/assets/style.css.tmpl | 157 | ||||
| -rw-r--r-- | vrsite/bench_test.go | 2 | ||||
| -rw-r--r-- | vrsite/css.go | 171 | ||||
| -rw-r--r-- | vrsite/go.mod | 2 | ||||
| -rw-r--r-- | vrsite/go.sum | 4 | ||||
| -rw-r--r-- | vrsite/main.go | 14 | ||||
| -rw-r--r-- | vrsite/markdown.go | 259 | ||||
| -rw-r--r-- | vrsite/markdown_test.go | 244 | ||||
| -rw-r--r-- | vrsite/model.go | 114 | ||||
| -rw-r--r-- | vrsite/page.go | 84 | ||||
| -rw-r--r-- | vrsite/permalink_test.go | 210 | ||||
| -rw-r--r-- | vrsite/render.go | 147 | ||||
| -rw-r--r-- | vrsite/serve.go | 118 | ||||
| -rw-r--r-- | vrsite/static.go | 50 | ||||
| -rw-r--r-- | vrsite/templates/frag-note.tmpl | 57 | ||||
| -rw-r--r-- | vrsite/templates/layout.tmpl | 30 | ||||
| -rw-r--r-- | vrsite/templates/page-note.tmpl | 54 | ||||
| -rw-r--r-- | vrsite/theme.go | 315 | ||||
| -rw-r--r-- | vrsite/themes.go | 445 | ||||
| -rw-r--r-- | vrsite/themes_test.go | 294 |
27 files changed, 3031 insertions, 213 deletions
@@ -13,6 +13,10 @@ take a note while reading (ids count up from 1 per session): vr note -f FILE:10-42 TEXT... about a line range vr note -t struct -f FILE:10-42 TEXT... about a struct/enum/const/fn/... vr note -r REV -f FILE TEXT... about code in another change + vr note -reply 12 TEXT... reply to log-wide note #12 + +vr query -op note shows both the session-local id and the log-wide id used by +-reply and vrsite /note/N permalinks. REV is any jj revset; default @ (the working copy). Every command takes -log FILE for where to append the log @@ -45,6 +45,7 @@ type Entry struct { Pattern string `json:"pattern,omitempty"` Matches int `json:"matches,omitempty"` NoteID int `json:"note_id,omitempty"` + ReplyTo int `json:"reply_to,omitempty"` Type string `json:"type,omitempty"` Text string `json:"text,omitempty"` } @@ -94,9 +95,10 @@ usage: vr grep [-log FILE] [-r REV] PATTERN [PATH] search the working copy (rg) or another revision (jj file list/show); scope non-@ greps with PATH, they read every file through jj - vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] TEXT... + vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT... record a note about a file range or a kind of thing (struct, enum, - const, ...); ids count up from 1 per session + const, ...), optionally replying to a log-wide note number; ids count + up from 1 per session vr query [-log FILE] [-session S] [-op OP] [-file SUBSTR] [-n N] print logged entries, oldest first @@ -363,13 +365,20 @@ func cmdNote(args []string) { rev := fs.String("r", "@", "jj revision the note refers to") file := fs.String("f", "", "FILE[:START[-END]] the note is about") kind := fs.String("t", "", "kind of thing the note is about (struct, enum, const, ...)") + reply := fs.Int("reply", 0, "log-wide note number this note replies to") fs.Parse(args) text := strings.Join(fs.Args(), " ") if text == "" { - fatal("usage: vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] TEXT...") + fatal("usage: vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") + } + if *reply < 0 { + fatal("reply note must be positive") + } + if *reply > 0 && !hasNote(readLog(*logPath), *reply) { + fatal("no log-wide note #%d in %s", *reply, *logPath) } - e := Entry{Op: "note", Rev: *rev, Change: changeID(*rev), Type: *kind, Text: text} + e := Entry{Op: "note", Rev: *rev, Change: changeID(*rev), ReplyTo: *reply, Type: *kind, Text: text} if *file != "" { e.File, e.Start, e.End = parseFileRange(*file) } @@ -378,6 +387,21 @@ func cmdNote(args []string) { fmt.Printf("note %d\n", e.NoteID) } +// hasNote uses the same numbering as vrsite permalinks: note operations count +// from one over the append-only log, independent of their writers' sessions. +func hasNote(entries []Entry, id int) bool { + n := 0 + for _, e := range entries { + if e.Op == "note" { + n++ + if n == id { + return true + } + } + } + return false +} + func nextNoteID(logPath string) int { max := 0 for _, e := range readLog(logPath) { @@ -398,8 +422,16 @@ func cmdQuery(args []string) { fs.Parse(args) entries := readLog(*logPath) - var kept []Entry + type result struct { + Entry + logNoteID int + } + var kept []result + logNoteID := 0 for _, e := range entries { + if e.Op == "note" { + logNoteID++ + } if *session != "" && !strings.Contains(e.Session, *session) { continue } @@ -409,12 +441,13 @@ func cmdQuery(args []string) { if *file != "" && !strings.Contains(e.File, *file) { continue } - kept = append(kept, e) + kept = append(kept, result{Entry: e, logNoteID: logNoteID}) } if *n > 0 && len(kept) > *n { kept = kept[len(kept)-*n:] } - for _, e := range kept { + for _, r := range kept { + e := r.Entry ts := e.Time if t, err := time.Parse(time.RFC3339, e.Time); err == nil { ts = t.Format("01-02 15:04:05") @@ -438,7 +471,11 @@ func cmdQuery(args []string) { if e.File != "" { loc = " " + fileRange(e) } - fmt.Printf("%s #%d%s%s — %s\n", head, e.NoteID, tag, loc, e.Text) + rel := "" + if e.ReplyTo > 0 { + rel = fmt.Sprintf(" reply to log #%d", e.ReplyTo) + } + fmt.Printf("%s #%d [log #%d]%s%s%s — %s\n", head, e.NoteID, r.logNoteID, tag, loc, rel, e.Text) default: fmt.Printf("%s\n", head) } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..134f222 --- /dev/null +++ b/main_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestReplyToRoundTripsInTheLog(t *testing.T) { + b, err := json.Marshal(Entry{Op: "note", NoteID: 2, ReplyTo: 7, Text: "child"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"reply_to":7`) { + t.Fatalf("reply edge missing from %s", b) + } + var got Entry + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.ReplyTo != 7 { + t.Errorf("reply_to = %d, want 7", got.ReplyTo) + } +} + +func TestHasNoteUsesLogWideNumbers(t *testing.T) { + entries := []Entry{ + {Op: "read"}, + {Op: "note", Session: "a", NoteID: 1}, + {Op: "note", Session: "b", NoteID: 1}, + } + if !hasNote(entries, 2) { + t.Error("second log-wide note was not found when its session-local id repeated") + } + if hasNote(entries, 3) || hasNote(entries, 0) { + t.Error("a nonexistent log-wide note was accepted") + } +} diff --git a/query-doc.txt b/query-doc.txt index 2887bfc..7c0cfdc 100644 --- a/query-doc.txt +++ b/query-doc.txt @@ -8,3 +8,5 @@ vr query — inspect the read/note log (one line per entry, oldest first). vr query -log FILE read another log (default ./vr-log.jsonl, or $VR_LOG) Filters combine. The log itself is plain JSON lines — jq works on it too. +Note rows show the session-local id first and the log-wide id used by +`vr note -reply N` and vrsite permalinks in brackets. diff --git a/vr-log.jsonl b/vr-log.jsonl index 4d1dbe3..a2f3e10 100644 --- a/vr-log.jsonl +++ b/vr-log.jsonl @@ -52,3 +52,69 @@ {"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","start":1580,"end":1630} {"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":630,"end":755} {"time":"2026-07-30T15:22:34-03:00","op":"note","session":"web:goblin","agent":"goblin","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"vsrmxqunvnxxzylkqlpkzpkmonulkzwn","file":"vr-log.jsonl","start":9,"end":9,"note_id":1,"text":"example note"} +{"time":"2026-08-01T18:01:25-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":".","pattern":"type (Entry|Note|PageData|note)|func .*note|Op.*note|case \"note\"|/notes|frag-note|note-form|permalink","matches":83} +{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":1,"end":491} +{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":1,"end":540} +{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main.go","start":1,"end":229} +{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/render.go","start":1,"end":460} +{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":1,"end":294} +{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":1,"end":756} +{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":350,"end":460} +{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"doc.txt","start":1,"end":19} +{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":1,"end":190} +{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":190,"end":380} +{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":380,"end":480} +{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":1,"end":220} +{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":63} +{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-note.tmpl","start":1,"end":41} +{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-notes.tmpl","start":1,"end":52} +{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-file.tmpl","start":1,"end":20} +{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/layout.tmpl","start":1,"end":88} +{"time":"2026-08-01T18:01:39-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","pattern":"note-card|note-|comment|textarea|form|focus","matches":27} +{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":280,"end":440} +{"time":"2026-08-01T18:01:39-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/app.js","pattern":"note|textarea|reply|form","matches":32} +{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/app.js","start":1,"end":426} +{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":250,"end":490} +{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/render.go","start":1,"end":230} +{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/static.go","start":160,"end":247} +{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main_test.go","start":1,"end":57} +{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":1,"end":240} +{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/static.go","start":1,"end":165} +{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown.go","start":1,"end":173} +{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/permalink_test.go","start":1,"end":198} +{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":187,"end":270} +{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":1,"end":190} +{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-index.tmpl","start":1,"end":56} +{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-change.tmpl","start":1,"end":37} +{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-session.tmpl","start":1,"end":70} +{"time":"2026-08-01T18:02:43-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite","pattern":"newTemplates|Funcs|dict|md|NoteHref","matches":70} +{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":400,"end":775} +{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":103,"end":160,"note_id":1,"type":"invariant","text":"Public note permalinks use a log-wide ID counted over note entries, while note_id remains session-local; reply edges should therefore target the log-wide ID."} +{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":417,"end":465,"note_id":2,"type":"flow","text":"The focused /note/{id} handler starts from the whole-log model before choosing source context, so it is the natural place to render cross-change reply and mention backlinks."} +{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":26,"note_id":3,"type":"ui","text":"Every note surface shares the note card template; a relation indicator added here will cover lists, file asides, conversations, and focused threads consistently."} +{"time":"2026-08-01T18:08:23-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","pattern":"ReplyTo|reply_to|newNoteLinkParser"} +{"time":"2026-08-01T18:08:23-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"doc.txt","start":8,"end":19} +{"time":"2026-08-01T18:08:23-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"query-doc.txt","start":1,"end":10} +{"time":"2026-08-01T18:11:16-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":490,"end":515} +{"time":"2026-08-01T18:11:16-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","pattern":"@media","matches":2} +{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown_test.go","start":1,"end":80} +{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":240,"end":400} +{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":617,"end":700} +{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/permalink_test.go","start":1,"end":60} +{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":35,"end":130} +{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main_test.go","start":1,"end":38} +{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown_test.go","start":55,"end":105} +{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":32,"end":110} +{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":350,"end":475} +{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":20,"end":190} +{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":300,"end":435} +{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown.go","start":70,"end":175} +{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":190,"end":285} +{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":417,"end":605} +{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":105} +{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-note.tmpl","start":1,"end":54} +{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":338,"end":408,"note_id":4,"type":"graph","text":"Reply edges come from reply_to while mention edges come only from local Markdown note links; recursive expansion uses a global seen set so arbitrary-depth DAG joins and cycles terminate as references."} +{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":524,"end":562,"note_id":5,"type":"validation","text":"Browser replies resolve their target against the whole-log model before append, while preserving the change currently being read and leaving mentions as ordinary note text."} +{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":40,"end":76,"note_id":6,"type":"ui","text":"The focused thread recursively renders reply and mention edges at arbitrary depth, with one normal POST reply form under each full card and compact references for repeated DAG nodes."} +{"time":"2026-08-01T18:20:12-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":60,"end":94,"note_id":7,"reply_to":20,"type":"test","text":"The model test covers a four-edge reply chain plus a note reached through both a reply and a mention, proving arbitrary recursion and DAG join handling."} +{"time":"2026-08-01T18:20:28-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main.go","start":1,"end":130} diff --git a/vrsite/app_test.go b/vrsite/app_test.go index 4db74d4..403903a 100644 --- a/vrsite/app_test.go +++ b/vrsite/app_test.go @@ -31,6 +31,12 @@ func testSite(t *testing.T) *Site { return s } +// testThemes stands in for whatever themes this machine happens to have, so +// the picker's two files are the same in every test run. +func testThemes(dark, light *Theme) *themeSet { + return themesFrom([]*Theme{dark, light}, dark, light) +} + func TestBuildModel(t *testing.T) { s := testSite(t) if s.Change != "aaa" { @@ -51,6 +57,42 @@ func TestBuildModel(t *testing.T) { } } +func TestNoteThreadRecursesThroughRepliesAndMentions(t *testing.T) { + entries := parseLog([]byte(logLines( + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":1,"text":"root"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":2,"reply_to":1,"text":"child"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":3,"reply_to":2,"text":"grandchild"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":4,"text":"[mentions root](../note/1.html)"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":5,"reply_to":3,"text":"[also mentions child](note/2)"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":6,"reply_to":5,"text":"arbitrarily deeper"}`, + ))) + s := buildModel("test", entries, "") + root, child := s.NoteByID(1), s.NoteByID(2) + if len(root.Replies) != 1 || root.Replies[0].ID != 2 || len(root.Mentions) != 1 || root.Mentions[0].ID != 4 { + t.Fatalf("root edges = replies %+v, mentions %+v", root.Replies, root.Mentions) + } + if len(child.Mentions) != 1 || child.Mentions[0].ID != 5 { + t.Fatalf("child mentions = %+v", child.Mentions) + } + + thread := s.NoteThread(1) + if thread == nil || len(thread.Children) != 2 || thread.Children[0].Via != "reply" || thread.Children[1].Via != "mention" { + t.Fatalf("root thread = %+v", thread) + } + // 1 → 2 → 3 → 5 → 6 proves the renderer's data is recursive rather + // than a fixed parent/child pair. + deep := thread.Children[0].Children[0].Children[0] + if deep.Note.ID != 5 || len(deep.Children) != 1 || deep.Children[0].Note.ID != 6 { + t.Fatalf("deep reply branch = %+v", deep) + } + // Note 5 also mentions note 2. It is fully rendered under note 3, then left + // as a DAG reference on the second path instead of recursing twice. + join := thread.Children[0].Children[1] + if join.Note.ID != 5 || !join.Reference || join.Via != "mention" { + t.Errorf("DAG join = %+v", join) + } +} + // A ninth agent gets no color instead of overflowing the uint8 mask (and the // per-mask CSS it would generate). func TestAgentColorsAreBounded(t *testing.T) { @@ -326,7 +368,8 @@ func testServer(t *testing.T) (*server, string, string) { if err := os.WriteFile(logPath, []byte(logLines(entry, note)), 0o644); err != nil { t.Fatal(err) } - sv := newServer(logPath, repo, "test", "tester", loadTheme("", "", "dark"), loadTheme("", "", "light")) + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + sv := newServer(logPath, repo, "test", "tester", dark, light, testThemes(dark, light)) return sv, logPath, change } @@ -352,7 +395,9 @@ func TestServerPages(t *testing.T) { {"/code/hello.txt", "three"}, {"/jj", "revset"}, {"/change/" + change, "first change"}, - {"/style.css", "--am1"}, + {"/style.css", ".am1 {"}, + {"/themes.css", `:root[data-theme="zed/one-light"]`}, + {"/themes.js", "VRSITE_THEMES"}, {"/app.js", "vrsite-mode"}, {"/htmx.min.js", "htmx"}, } { @@ -553,7 +598,7 @@ func TestServerHoldsNoStateOfItsOwn(t *testing.T) { h.ServeHTTP(httptest.NewRecorder(), r) // a second server that has only ever seen the log file - fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light).handler() + fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler() for _, path := range []string{"/", "/notes", "/notes?scope=all", "/code/hello.txt", "/files", "/tree", "/change/" + change} { was := agoRe.ReplaceAllString(get(t, h, path, false).Body.String(), "AGO") @@ -662,6 +707,43 @@ func TestAddNoteAppendsToLog(t *testing.T) { } } +func TestFocusedNoteRendersAndAppendsAnArbitrarilyDeepThread(t *testing.T) { + sv, logPath, change := testServer(t) + h := sv.handler() + post := func(values url.Values) { + t.Helper() + values.Set("change", change) + r := httptest.NewRequest("POST", "/notes", strings.NewReader(values.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("POST /notes = %d: %s", w.Code, w.Body) + } + } + post(url.Values{"reply_to": {"1"}, "text": {"child from focused view"}}) + post(url.Values{"reply_to": {"2"}, "text": {"grandchild from focused view"}}) + post(url.Values{"text": {"[backlink to root](/note/1)"}}) + + entries := parseLog(mustRead(t, logPath)) + if len(entries) < 5 || entries[len(entries)-3].ReplyTo != 1 || entries[len(entries)-2].ReplyTo != 2 || entries[len(entries)-1].ReplyTo != 0 { + t.Fatalf("thread entries were not appended as explicit replies plus a plain mention: %+v", entries) + } + body := get(t, h, "/note/1", false).Body.String() + for _, want := range []string{ + "child from focused view", "grandchild from focused view", "backlink to root", + `class="threadnode via-reply"`, `class="threadnode via-mention"`, + `name="reply_to" value="2"`, "reply to #2", "Replies & mentions", + } { + if !strings.Contains(body, want) { + t.Errorf("focused thread is missing %q", want) + } + } + if strings.Index(body, "grandchild from focused view") < strings.Index(body, "child from focused view") { + t.Error("grandchild rendered outside its parent branch") + } +} + func TestAddNoteRejectsCrossOriginAndEmptyText(t *testing.T) { sv, _, _ := testServer(t) h := sv.handler() @@ -683,6 +765,16 @@ func TestAddNoteRejectsCrossOriginAndEmptyText(t *testing.T) { if w.Code != http.StatusBadRequest { t.Errorf("empty note = %d, want 400", w.Code) } + + r = httptest.NewRequest("POST", "/notes", strings.NewReader(url.Values{ + "text": {"orphan"}, "reply_to": {"999"}, + }.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("missing reply target = %d, want 400", w.Code) + } } func TestJJQueries(t *testing.T) { diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js index 104cc85..696041c 100644 --- a/vrsite/assets/app.js +++ b/vrsite/assets/app.js @@ -32,13 +32,202 @@ document.addEventListener("keydown", e => { }); // ── theme ───────────────────────────────────────────────────────────────── -const themesel = document.getElementById("themesel"); -if (themesel) { - themesel.value = localStorage.getItem("vrsite-theme") || "system"; - themesel.addEventListener("change", () => { - localStorage.setItem("vrsite-theme", themesel.value); - if (themesel.value === "system") delete root.dataset.theme; - else root.dataset.theme = themesel.value; +// Every theme this machine has is a :root block in themes.css, so wearing one +// is a single attribute — and the menu can therefore preview as it moves. +// Arrow through it or hover and the page changes under you; enter keeps what +// you are wearing, escape puts back the one you came with. +// +// themes.css and themes.js are fetched the first time the menu opens (the +// stylesheet earlier than that, from the head, when a theme is already +// chosen), so a reader who never switches pays nothing for them. +const pick = document.getElementById("themepick"); +if (pick) { + const btn = document.getElementById("themebtn"), + menu = document.getElementById("thememenu"), + find = document.getElementById("themefind"), + list = document.getElementById("themelist"); + const builtin = [ + {id: "system", name: "system", src: "follows the browser"}, + {id: "dark", name: pick.dataset.dark, src: "built with · dark"}, + {id: "light", name: pick.dataset.light, src: "built with · light"}, + ]; + let cur = localStorage.getItem("vrsite-theme") || "system"; + let active = null, filled = false, pending = null; + + const wear = id => { + if (id === "system") delete root.dataset.theme; + else root.dataset.theme = id; + }; + // the name of a theme we may not have the menu for yet: kept beside the id + // so the button says something true before themes.js has loaded + const label = id => { + const b = builtin.find(t => t.id === id); + if (b) return b.name; + const t = (window.VRSITE_THEMES || []).find(t => t.id === id); + return t ? t.name : localStorage.getItem("vrsite-theme-name") || + id.replace(/^[^/]*\//, "").replace(/[-_]/g, " "); + }; + btn.textContent = label(cur); + + // themes.css is a link and themes.js a script, not a fetch: the static + // export is opened from the filesystem, where fetch() is not allowed + const ensure = () => { + if (!document.getElementById("themescss")) { + const l = document.createElement("link"); + l.rel = "stylesheet"; l.id = "themescss"; l.href = pick.dataset.css; + document.head.append(l); + } + if (window.VRSITE_THEMES) return Promise.resolve(); + if (!pending) pending = new Promise(done => { + const s = document.createElement("script"); + s.src = pick.dataset.js; + s.onload = s.onerror = done; + document.head.append(s); + }); + return pending; + }; + + const row = t => { + const li = document.createElement("li"); + li.setAttribute("role", "option"); + li.id = "themeopt-" + list.children.length; + li.dataset.id = t.id; + li.dataset.q = (t.name + " " + t.src + " " + t.id).toLowerCase(); + const sw = document.createElement("span"); + sw.className = t.sw ? "sw" : "sw none"; // empty, but the names still line up + for (const c of t.sw || []) { + const chip = document.createElement("i"); + chip.style.background = c; + sw.append(chip); + } + li.append(sw); + const nm = document.createElement("span"); + nm.className = "nm"; + nm.textContent = t.name; + const src = document.createElement("span"); + src.className = "src"; + src.textContent = t.src; + li.append(nm, src); + return li; + }; + + const fill = () => { + if (filled) return; + filled = true; + // the two the site was built with are usually in the menu as well, so the + // rows for them can wear the same swatch — and "system", both at once + const all = window.VRSITE_THEMES || []; + const swOf = id => (all.find(t => t.id === id) || {}).sw; + const dsw = swOf(pick.dataset.darkid), lsw = swOf(pick.dataset.lightid); + builtin[0].sw = dsw && lsw && [...dsw.slice(0, 3), ...lsw.slice(0, 3)]; + builtin[1].sw = dsw; + builtin[2].sw = lsw; + for (const t of builtin) list.append(row(t)); + for (const t of window.VRSITE_THEMES || []) + list.append(row({...t, src: t.src + (t.dark ? " · dark" : " · light")})); + if (list.children.length === builtin.length) { + const p = document.createElement("li"); + p.className = "meta"; + p.textContent = "no zed or helix themes found on this machine"; + list.append(p); + } + }; + + const setActive = (li, preview = true) => { + if (!li || li === active) return; + if (active) { + active.classList.remove("on"); + active.removeAttribute("aria-selected"); + } + active = li; + li.classList.add("on"); + li.setAttribute("aria-selected", "true"); + find.setAttribute("aria-activedescendant", li.id); + li.scrollIntoView({block: "nearest"}); + if (preview) wear(li.dataset.id); + }; + + const rows = () => [...list.querySelectorAll("li[data-id]:not([hidden])")]; + const rowFor = id => list.querySelector('li[data-id="' + CSS.escape(id) + '"]'); + + const filter = () => { + const q = find.value.trim().toLowerCase(); + for (const li of list.children) + li.hidden = !!q && !(li.dataset.q || "").includes(q); + if (!active || active.hidden) setActive(rows()[0]); + }; + find.addEventListener("input", filter); + + const open = () => { + if (!menu.hidden) return; + menu.hidden = false; + btn.setAttribute("aria-expanded", "true"); + find.value = ""; + find.focus(); + ensure().then(() => { + fill(); + filter(); + for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur"); + const here = rowFor(cur); + if (here) here.classList.add("cur"); + setActive(here || rows()[0], false); + }); + }; + + // leaving the menu either keeps what is on screen or puts back what was + // there before it opened — nothing is stored until it is kept + const close = keep => { + if (menu.hidden) return; + if (keep && active) { + cur = active.dataset.id; + localStorage.setItem("vrsite-theme", cur); + localStorage.setItem("vrsite-theme-name", label(cur)); + btn.textContent = label(cur); + for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur"); + active.classList.add("cur"); + } + wear(cur); + menu.hidden = true; + btn.setAttribute("aria-expanded", "false"); + }; + + btn.addEventListener("click", () => (menu.hidden ? open() : close(false))); + // mousemove, not mouseover: arrowing scrolls rows under a still pointer, + // and the pointer must not then take the selection back + list.addEventListener("mousemove", e => { + const li = e.target.closest("li[data-id]"); + if (li) setActive(li); + }); + list.addEventListener("click", e => { + const li = e.target.closest("li[data-id]"); + if (li) { setActive(li); close(true); } + }); + find.addEventListener("keydown", e => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const all = rows(); + if (!all.length) return; + const i = all.indexOf(active), step = e.key === "ArrowDown" ? 1 : -1; + setActive(all[(i + step + all.length) % all.length]); + } else if (e.key === "Enter") { + e.preventDefault(); + close(true); + btn.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + close(false); + btn.focus(); + } + }); + document.addEventListener("click", e => { + if (!menu.hidden && !pick.contains(e.target)) close(false); + }); + document.addEventListener("keydown", e => { + if (e.key === "Escape") close(false); + if (e.key === "T" && !e.metaKey && !e.ctrlKey && !e.altKey && !typing(e)) { + e.preventDefault(); // or the T lands in the filter we just focused + open(); + } }); } @@ -165,6 +354,10 @@ for (const w of document.querySelectorAll(".notewrap[data-href]")) { // ?note=N means someone followed a note here rather than opening the file: // show what they came for — notes focus (coverage hides the note), the card // flashed in the pane, the lines it covers marked. +// A permalink is the same arrival with nothing to scroll to: the page is the +// note, so it only needs the focus that shows notes at all. +if (document.body.dataset.kind === "note") setMode("notes"); + const want = new URLSearchParams(location.search).get("note"); if (want && document.body.dataset.kind === "file" && /^\d+$/.test(want)) { setMode("notes"); @@ -200,7 +393,10 @@ if (filters) { from = val("from"), to = val("to"); let shown = 0; for (const art of document.querySelectorAll("#notelist .note")) { - const d = art.dataset, body = art.querySelector("p").textContent.toLowerCase(); + // the whole rendered body, not its first <p>: a note is markdown, so it + // may open with a list or a code fence and run to several blocks + const d = art.dataset, el = art.querySelector(".body"); + const body = (el ? el.textContent : "").toLowerCase(); const ok = (!text || body.includes(text)) && (!file || (d.file || "").toLowerCase().includes(file)) && diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl index f57e454..9418087 100644 --- a/vrsite/assets/style.css.tmpl +++ b/vrsite/assets/style.css.tmpl @@ -111,7 +111,7 @@ nav .brand { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .note.away:hover { opacity: 1; } .atchg.away { color: var(--note); } .atchg.away code { color: inherit; } -.find, .filters input, .filters select, .notform input, .notform textarea, .jjform input { +.find, .filters input, .filters select, .notform input, .notform textarea, .jjform input, .thememenu input { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; font: inherit; font-size: 12px; padding: 3px 6px; @@ -153,11 +153,44 @@ nav > footer { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } -nav > footer select { + +/* ── the theme picker ────────────────────────────────────────── + Every theme on the machine, previewed as you move through it. The + menu is fixed rather than absolute so the scrolling tree above it + cannot clip it. */ +#themebtn { background: var(--panel); color: var(--muted); border: 1px solid var(--border); border-radius: 4px; - font: inherit; font-size: 11px; padding: 1px 2px; + font: inherit; font-size: 11px; padding: 1px 6px; cursor: pointer; + max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +#themebtn:hover, #themebtn[aria-expanded="true"] { color: var(--fg); background: var(--hover); } +.thememenu { + position: fixed; left: 8px; bottom: 8px; z-index: 20; + width: 264px; max-height: min(60vh, 520px); + display: flex; flex-direction: column; gap: 6px; + background: var(--panel); border: 1px solid var(--border); + border-radius: 7px; padding: 8px; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.35); } +.thememenu input { width: 100%; } +.thememenu ul { list-style: none; margin: 0; padding: 0; overflow: auto; flex: 1; } +.thememenu li { + display: flex; align-items: center; gap: 7px; + padding: 3px 5px; border-radius: 4px; cursor: pointer; + font-size: 12px; color: var(--fg); +} +/* the menu is hidden until it is opened and the filter hides rows: both are + the hidden attribute, which a display of our own would otherwise outrank */ +.thememenu[hidden], .thememenu li[hidden] { display: none; } +.thememenu li.on { background: var(--hover); } +.thememenu li .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.thememenu li .src { margin-left: auto; color: var(--muted); font-size: 10px; white-space: nowrap; } +.thememenu li.cur .nm::after { content: " ✓"; color: var(--accent); } +.thememenu .sw { display: flex; flex: none; border: 1px solid var(--border); border-radius: 3px; overflow: hidden; } +.thememenu .sw i { display: block; width: 7px; height: 14px; } +.thememenu .sw.none { width: 42px; border-color: transparent; } +.thememenu .hint { margin: 0; font-size: 10px; } /* files.html full tree */ .filetree { font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; padding-bottom: 40vh; } @@ -256,14 +289,115 @@ aside summary { cursor: pointer; color: var(--muted); } .note header b { margin-left: auto; color: var(--cov); font-weight: 600; } .note p { margin: 6px 0 0; line-height: 1.45; } .note.flash { animation: flash 1.2s ease-out; } + +/* A note body is markdown. It is prose about code inside a card that is + itself only ~320px wide in the columns, so every block here is tuned down + from document scale: headings barely outrank the text, rules and quotes are + hairlines, and nothing may push the card wider than its column. The + selectors carry .body so they outrank .turn p in the conversation view, + where notes are cards too. */ +.note .body > :first-child { margin-top: 6px; } +.note .body p, +.note .body ul, .note .body ol, .note .body blockquote, +.note .body pre, .note .body table { margin: 6px 0 0; } +.note .body p { line-height: 1.45; white-space: normal; } +.note .body :is(h1, h2, h3, h4, h5, h6) { + margin: 10px 0 0; font-size: 1em; font-weight: 600; line-height: 1.3; +} +.note .body h1, .note .body h2 { color: var(--fg); font-size: 1.05em; } +.note .body ul, .note .body ol { padding-left: 1.3em; } +.note .body li { margin: 2px 0; line-height: 1.45; } +.note .body li > p { margin: 0; } +.note .body li::marker { color: var(--muted); } +.note .body blockquote { + border-left: 2px solid var(--border); padding-left: 8px; color: var(--muted); +} +.note .body hr { border: none; border-top: 1px solid var(--border); margin: 10px 0 0; } +.note .body code { background: var(--hover); border-radius: 3px; padding: 0 3px; } +.note .body a { overflow-wrap: anywhere; } +.note .body img { max-width: 100%; height: auto; } +/* a fenced block: the code view's spans without its line-number scaffolding */ +.note .body pre.mdcode { + background: var(--panel); border: 1px solid var(--border); border-radius: 4px; + padding: 6px 8px; overflow-x: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; + line-height: 1.4; tab-size: 4; +} +.note .body pre.mdcode code { background: none; padding: 0; font-size: inherit; } +.note .body table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 0.95em; } +.note .body :is(th, td) { border: 1px solid var(--border); padding: 2px 6px; text-align: left; } +.note .body th { color: var(--muted); font-weight: 600; } +.note .body input[type="checkbox"] { margin-right: 4px; vertical-align: -1px; } @keyframes flash { 0% { background: color-mix(in srgb, var(--cov) 30%, var(--bg)); } 100% { background: var(--bg); } } .note footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 11px; } .note footer code { color: var(--muted); } .note .model { opacity: 0.85; } .note .conv { margin-left: auto; } +.note .parent { color: var(--muted); } +.note .relations { + color: var(--note); text-decoration: none; + background: var(--note-bg); border-radius: 3px; padding: 0 4px; +} +.note .relations:hover { color: var(--accent); text-decoration: underline; } .notecols { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0 14px; } +/* the permalink: the one link on a card that names nothing but the note, so + it is the one to hand someone else. Quiet until wanted. */ +.note .perma { color: var(--ln); font-variant-numeric: tabular-nums; text-decoration: none; } +.note .perma:hover { color: var(--accent); text-decoration: underline; } +.note .perma.self { color: var(--muted); } + +/* ── one note, on its own page ───────────────────────────────── + What a shared link opens: the note, then the lines it is about. Wider + than a card in a list — nothing is competing for the room here. */ +.notepage { padding-bottom: 30vh; } +.notepage .onenote .note { margin: 0 0 14px; max-width: 760px; } +.notepage .exhead { margin: 0 0 6px; } +.notepage .excerpt { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); } +.notepage .excerpt ol.code { padding: 6px 0; } +.notepage .excerpt ol.code:last-of-type { padding-bottom: 6px; } +.notepage .more { margin-top: 20px; } +.filehead h1 .lns { color: var(--muted); font-weight: 400; } +.filehead .kind { color: var(--note); font-style: normal; border: 1px solid var(--note); border-radius: 3px; padding: 0 4px; font-size: 11px; } +.filehead .ago { color: var(--muted); } +/* the excerpt exists to show what the note is about, so its lines stay + marked in either focus — the toggle is about reading a whole file */ +:root[data-mode="trace"] .excerpt ol.code li.noted { background: var(--note-bg); cursor: default; } +:root[data-mode="trace"] .excerpt ol.code li.noted::before { color: color-mix(in srgb, var(--note) 55%, var(--ln)); } + +/* Incoming reply and mention edges share one recursively nested discussion. + The narrow rail is intentionally HN-like: enough hierarchy to scan without + turning the existing cards into a second design system. */ +.discussion { max-width: 820px; margin-top: 24px; scroll-margin-top: 16px; } +.discussion h2 { margin-bottom: 6px; } +.discussion h2 .meta { font-size: 11px; font-weight: 400; margin-left: 6px; } +.threadnode { + display: grid; grid-template-columns: 20px minmax(0, 1fr); + align-items: stretch; +} +.threadedge { position: relative; color: var(--muted); text-align: center; font-size: 11px; } +.threadedge::after { + content: ""; position: absolute; top: 20px; bottom: 0; left: 50%; + border-left: 1px solid var(--border); +} +.threadedge span { position: relative; z-index: 1; background: var(--bg); } +.via-mention > .threadedge { color: var(--note); } +.via-mention > .threadedge::after { border-left-style: dashed; border-left-color: var(--note); } +.threadcontent { min-width: 0; } +.threadcontent > .note { margin: 7px 0; } +.threadchildren { min-width: 0; } +.threadref { + margin: 8px 0; padding: 5px 8px; border: 1px dashed var(--border); + border-radius: 5px; background: var(--panel); +} +.replybox { max-width: 760px; margin: 4px 0 10px; } +.replybox > summary { cursor: pointer; color: var(--muted); font-size: 11px; } +.replybox[open] > summary { color: var(--accent); } +.replyform { margin: 5px 0 10px; padding-left: 8px; border-left: 2px solid var(--border); } +.replyform textarea { width: 100%; box-sizing: border-box; } +.threadnode .replybox { margin-left: 1px; } + /* ── conversation ──────────────────────────────────────────── */ .convpage { padding: 0 16px 40vh; max-width: 1100px; } .turn { @@ -403,13 +537,22 @@ table.changed .st { color: var(--muted); font-size: 11px; width: 7ch; } table.changed .st-added { color: var(--accent); } table.changed .st-deleted { color: var(--note); } -/* ── agent coverage colors (bitmask -> per-scheme mixed color) */ -{{range .MaskList}}.am{{.}} { --cov: var(--am{{.}}); } +/* ── agent coverage colors ────────────────────────────────── + A bitmask is its agents' colors averaged. The blend is left to the + browser so these rules hold for any theme: a theme only has to name + --ag0..7, which every theme in themes.css does. */ +{{range .Masks}}.am{{.Mask}} { --cov: {{.Mix}}; } {{end}} -/* ── syntax (zed theme, colors are per-scheme vars) ────────── */ -{{range .Syntax}}.s-{{.Class}} { color: var(--s-{{.Class}}, inherit);{{if eq .Style "italic"}} font-style: italic;{{end}}{{if .Weight}} font-weight: {{.Weight}};{{end}} } +/* ── syntax (theme colors, italics and weights are vars too, so + switching theme switches those with the colors) ─────────── */ +{{range .Syntax}}.s-{{.Class}} { color: var(--s-{{.Class}}, inherit); font-style: var(--s-{{.Class}}-fs, inherit); font-weight: var(--s-{{.Class}}-fw, inherit); } {{end}} @media (max-width: 1100px) { body, body.with-aside { grid-template-columns: 1fr; } nav, aside { position: static; height: auto; border: none; border-bottom: 1px solid var(--border); } } +@media (max-width: 620px) { + .threadnode { grid-template-columns: 14px minmax(0, 1fr); } + .discussion h2 .meta { display: block; margin: 2px 0 0; } + .replyform { padding-left: 5px; } +} diff --git a/vrsite/bench_test.go b/vrsite/bench_test.go index c3eac0c..6e3f907 100644 --- a/vrsite/bench_test.go +++ b/vrsite/bench_test.go @@ -268,7 +268,7 @@ func BenchmarkRender(b *testing.B) { // template execution for an already-rendered file page func BenchmarkExecute(b *testing.B) { hl := benchHL(b) - tpl := newTemplates() + tpl := newTemplates(hl) site := &Site{ Title: "bench", Files: map[string]*File{}, Theme: loadTheme("", "", "dark"), Light: loadTheme("", "", "light"), diff --git a/vrsite/css.go b/vrsite/css.go index a2ca377..f1df936 100644 --- a/vrsite/css.go +++ b/vrsite/css.go @@ -1,68 +1,157 @@ -// css.go — the stylesheet is generated per site: theme colors and the mixed -// per-agent coverage colors become custom properties, one block per scheme. +// css.go — the stylesheet is generated per site: theme colors become custom +// properties, one block per scheme, and the per-agent coverage colors are +// blended from them in the browser so any theme can supply them. +// +// Everything switchable is a custom property on :root. That is what lets +// themes.css hold nothing but one block per theme (writeThemesCSS) and the +// picker repaint the page by setting a single attribute. package main import ( + "encoding/json" "fmt" "io" - "sort" "strings" texttemplate "text/template" ) type cssVar struct{ K, V string } -type synCSS struct{ Class, Style, Weight string } +type synCSS struct{ Class string } + +type maskCSS struct { + Mask uint8 + Mix string +} func renderCSS(w io.Writer, site *Site) error { css, err := texttemplate.ParseFS(tfs, "assets/style.css.tmpl") if err != nil { return err } - var unionKeys []string - seen := map[string]bool{} - for _, t := range []*Theme{site.Theme, site.Light} { - for k := range t.Syntax { - if !seen[k] { - seen[k] = true - unionKeys = append(unionKeys, k) - } + keys := syntaxKeys(site.Theme, site.Light) + var syn []synCSS + for _, k := range keys { + syn = append(syn, synCSS{Class: cssKey(k)}) + } + var masks []maskCSS + for _, m := range site.masks() { + masks = append(masks, maskCSS{Mask: m, Mix: site.maskMix(m)}) + } + return css.Execute(w, map[string]any{ + "DarkName": site.Theme.Name, "LightName": site.Light.Name, + "DarkVars": themeVars(site.Theme, keys), "LightVars": themeVars(site.Light, keys), + "Syntax": syn, "Masks": masks, + }) +} + +// themeVars is one theme as custom properties: its own colors, the syntax +// colors under the keys the site paints with (resolved along their dots, so a +// theme that only names "punctuation" still colors brackets), and the agent +// colors every coverage blend is mixed from. +func themeVars(t *Theme, keys []string) []cssVar { + // bg and panel are what everything else is read against, so they are the + // two that may not carry a theme's alpha channel through + vars := []cssVar{ + {"--bg", hex6(t.Bg)}, {"--fg", t.Fg}, {"--panel", hex6(t.Panel)}, {"--border", t.Border}, + {"--ln", t.LineNo}, {"--muted", t.Muted}, {"--accent", t.Accent}, + {"--note", t.Note}, {"--hover", t.Hover}, + } + for _, k := range keys { + key := resolveKey(t, k) + if key == "" { + continue + } + s, c := t.Syntax[key], cssKey(k) + vars = append(vars, cssVar{"--s-" + c, s.Color}) + if s.FontStyle != nil && *s.FontStyle != "" && *s.FontStyle != "normal" { + vars = append(vars, cssVar{"--s-" + c + "-fs", *s.FontStyle}) + } + if s.FontWeight != nil && *s.FontWeight > 0 { + vars = append(vars, cssVar{"--s-" + c + "-fw", fmt.Sprintf("%.0f", *s.FontWeight)}) } } - sort.Strings(unionKeys) - masks := site.masks() - themeVars := func(t *Theme) []cssVar { - vars := []cssVar{ - {"--bg", t.Bg}, {"--fg", t.Fg}, {"--panel", t.Panel}, {"--border", t.Border}, - {"--ln", t.LineNo}, {"--muted", t.Muted}, {"--accent", t.Accent}, - {"--note", t.Note}, {"--hover", t.Hover}, + for i := 0; i < maxAgents; i++ { + vars = append(vars, cssVar{fmt.Sprintf("--ag%d", i), hex6(t.Player(i))}) + } + return vars +} + +// maskMix is the color of a coverage bitmask: the agent colors it stands for, +// averaged. The average is written as nested color-mix rather than mixed here, +// so the same rule holds for every theme the browser can switch to. +func (s *Site) maskMix(mask uint8) string { + var idx []int + for i, a := range s.Agents { + if a.Bit != 0 && mask&a.Bit != 0 { + idx = append(idx, i) } - for _, k := range unionKeys { - if s, ok := t.Syntax[k]; ok && s.Color != "" { - vars = append(vars, cssVar{"--s-" + strings.ReplaceAll(k, ".", "-"), s.Color}) - } + } + return mixExpr(idx) +} + +func mixExpr(idx []int) string { + switch len(idx) { + case 0: + return "var(--fg)" + case 1: + return fmt.Sprintf("var(--ag%d)", idx[0]) + } + return fmt.Sprintf("color-mix(in srgb, var(--ag%d) %.3f%%, %s)", + idx[0], 100/float64(len(idx)), mixExpr(idx[1:])) +} + +// ── the switchable themes ───────────────────────────────────────────────── + +// writeThemesCSS writes one block per theme found on this machine. It is only +// fetched once a theme has been picked (or the picker opened), so the page +// costs nothing for the reader who never switches. +func writeThemesCSS(w io.Writer, themes []*Theme, keys []string) { + fmt.Fprintf(w, "/* %d themes, found on this machine — set data-theme on :root to wear one */\n", len(themes)) + for _, t := range themes { + fmt.Fprintf(w, ":root[data-theme=%q] {\n", t.ID) + for _, v := range themeVars(t, keys) { + fmt.Fprintf(w, " %s: %s;\n", v.K, v.V) } - for _, m := range masks { - vars = append(vars, cssVar{fmt.Sprintf("--am%d", m), site.maskColor(t, m)}) + scheme := "dark" + if t.Appearance == "light" { + scheme = "light" } - return vars + fmt.Fprintf(w, " color-scheme: %s;\n}\n", scheme) } - var syn []synCSS - for _, k := range unionKeys { - s := synCSS{Class: strings.ReplaceAll(k, ".", "-")} - if v, ok := site.Theme.Syntax[k]; ok { - if v.FontStyle != nil { - s.Style = *v.FontStyle - } - if v.FontWeight != nil { - s.Weight = fmt.Sprintf("%.0f", *v.FontWeight) +} + +// themeCard is a theme as the picker shows it: a name, where it came from, and +// the few colors that make it recognizable at a glance. +type themeCard struct { + ID string `json:"id"` + Name string `json:"name"` + Src string `json:"src"` + Dark bool `json:"dark"` + Sw []string `json:"sw"` +} + +// writeThemesJS is the picker's menu. It is a script, not JSON, because the +// static export is opened from the filesystem, where fetch() is not allowed +// but a <script src> still loads. +func writeThemesJS(w io.Writer, themes []*Theme) { + cards := make([]themeCard, 0, len(themes)) + for _, t := range themes { + syn := func(key string) string { + if k := resolveKey(t, key); k != "" { + return hex6(t.Syntax[k].Color) } + return hex6(t.Fg) } - syn = append(syn, s) + cards = append(cards, themeCard{ID: t.ID, Name: t.Name, Src: t.Source, + Dark: t.Appearance != "light", + Sw: []string{hex6(t.Bg), hex6(t.Fg), hex6(t.Accent), + syn("keyword"), syn("string"), syn("function")}}) } - return css.Execute(w, map[string]any{ - "DarkName": site.Theme.Name, "LightName": site.Light.Name, - "DarkVars": themeVars(site.Theme), "LightVars": themeVars(site.Light), - "Syntax": syn, "MaskList": masks, - }) + b, err := json.Marshal(cards) + if err != nil { + b = []byte("[]") + } + fmt.Fprintf(w, "// %d themes, written by vrsite from this machine's zed and helix installs\n", len(cards)) + fmt.Fprintf(w, "window.VRSITE_THEMES=%s;\n", strings.ReplaceAll(string(b), "},{", "},\n{")) } diff --git a/vrsite/go.mod b/vrsite/go.mod index cb9dd9e..e4c19c8 100644 --- a/vrsite/go.mod +++ b/vrsite/go.mod @@ -3,6 +3,7 @@ module 0x4200.cafe/vrsite go 1.26.5 require ( + github.com/BurntSushi/toml v1.6.0 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-bash v0.25.1 github.com/tree-sitter/tree-sitter-c v0.24.2 @@ -13,6 +14,7 @@ require ( github.com/tree-sitter/tree-sitter-python v0.25.0 github.com/tree-sitter/tree-sitter-rust v0.24.2 github.com/tree-sitter/tree-sitter-typescript v0.23.2 + github.com/yuin/goldmark v1.8.5 ) require github.com/mattn/go-pointer v0.0.1 // indirect diff --git a/vrsite/go.sum b/vrsite/go.sum index af50473..12a46d2 100644 --- a/vrsite/go.sum +++ b/vrsite/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= @@ -36,5 +38,7 @@ github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvh github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI= github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU= github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/vrsite/main.go b/vrsite/main.go index 5f9b1ef..69c90a8 100644 --- a/vrsite/main.go +++ b/vrsite/main.go @@ -11,7 +11,9 @@ // repository is only ever read, never modified. // // Theme comes from a Zed theme JSON; per-agent colors come from the theme's -// player (collaborator) palette. +// player (collaborator) palette. Every other theme this machine has — Zed's +// and Helix's both — is offered in the picker, previewed as you move through +// it, and remembered in the browser. package main import ( @@ -69,6 +71,11 @@ and the app keeps no state of its own. Session transcripts (the conversation behind a note) are looked up by session id under -transcripts; Claude Code and Codex are found automatically. +The site is built with the two themes -theme and -light-theme name. Every +other theme installed on this machine — Zed families and Helix themes both — +is offered in the picker at the bottom of the tree: it previews as you move +through it, and the browser remembers what you settled on. + flags: ` @@ -112,15 +119,16 @@ func main() { } theme := loadTheme(*themePath, *themeName, "dark") light := loadTheme(*lightPath, *lightName, "light") + themes := newThemeSet(theme, light) if *out == "" { if *watch { fatal("-watch is for -out builds; the served app already follows the log") } - serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light) + serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes) return } - build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light) } + build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light, themes) } build() if !*watch { return diff --git a/vrsite/markdown.go b/vrsite/markdown.go new file mode 100644 index 0000000..31581ae --- /dev/null +++ b/vrsite/markdown.go @@ -0,0 +1,259 @@ +// markdown.go — note text is markdown. Agents write prose about code: +// backticked identifiers, bullet lists, the occasional fenced snippet, and +// links out. Rendering it as one escaped <p> threw all of that away. +// +// Two things make markdown fit text nobody wrote for a browser: +// +// - angle brackets are never markup. In a note they are code — +// RwLock<SysvarCache> is a type — so the HTML parsers come out and the +// brackets survive as the characters they were typed as. +// - fenced code goes through the same tree-sitter highlighter as the code +// view, so a snippet in a note is painted by the reader's theme exactly +// like the file it was copied from. +package main + +import ( + "bytes" + stdhtml "html" + "html/template" + "net/url" + "path" + "regexp" + "strconv" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + mdparser "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + mdtext "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// markdown renders note text. goldmark is safe for concurrent use, which the +// server (a goroutine per request) and the export (a goroutine per file) both +// rely on. +type markdown struct{ md goldmark.Markdown } + +// newMarkdown builds the note renderer. GFM adds the things people actually +// type — tables, ~~strikethrough~~, bare URLs, task lists — and hard wraps +// keep a newline a newline, since a note is typed into a textarea or passed on +// a command line, not authored as a document. +func newMarkdown(hl *highlighter) *markdown { + md := goldmark.New( + goldmark.WithParser(notesParser()), + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions(html.WithHardWraps()), + ) + // lower priority number wins: this one replaces the stock code-block + // renderers, everything else stays goldmark's + md.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(¬eRenderer{hl: hl}, 100))) + return &markdown{md: md} +} + +// goldmark's priorities for the two parsers that read angle brackets as +// markup, from mdparser.DefaultBlockParsers and mdparser.DefaultInlineParsers. +const ( + htmlBlockPriority = 900 + rawHTMLPriority = 400 +) + +// notesParser is goldmark's default parser with those two dropped. Leaving +// them in costs the note either way: goldmark's safe default deletes raw HTML +// outright, so "RwLock<SysvarCache>" loses a word, and the block form +// additionally stops parsing markdown for the rest of the paragraph — a note +// that opens with a quoted <div> would render its links and lists as source. +// Without the parsers a bracket is just a character the text renderer escapes, +// which is both what the writer meant and the one thing a browser cannot act +// on. Dropping by priority keeps every other default, including any upstream +// adds later. +func notesParser() mdparser.Parser { + return mdparser.NewParser( + mdparser.WithBlockParsers(without(mdparser.DefaultBlockParsers(), htmlBlockPriority)...), + mdparser.WithInlineParsers(without(mdparser.DefaultInlineParsers(), rawHTMLPriority)...), + mdparser.WithParagraphTransformers(mdparser.DefaultParagraphTransformers()...), + ) +} + +func without(vs []util.PrioritizedValue, priority int) []util.PrioritizedValue { + out := vs[:0] + for _, v := range vs { + if v.Priority != priority { + out = append(out, v) + } + } + return out +} + +// newNoteLinkParser is shared across one model build. It uses the same parser +// and GFM extensions as rendered note bodies, so only links the reader can +// actually click become mentions (code spans and plain prose do not). +func newNoteLinkParser() mdparser.Parser { + return goldmark.New( + goldmark.WithParser(notesParser()), + goldmark.WithExtensions(extension.GFM), + ).Parser() +} + +func noteLinks(p mdparser.Parser, source string) []int { + if !strings.Contains(source, "note") { + return nil + } + src := []byte(source) + doc := p.Parse(mdtext.NewReader(src)) + seen := map[int]bool{} + var ids []int + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering || n.Kind() != ast.KindLink { + return ast.WalkContinue, nil + } + id := noteLinkID(string(n.(*ast.Link).Destination)) + if id > 0 && !seen[id] { + seen[id] = true + ids = append(ids, id) + } + return ast.WalkContinue, nil + }) + return ids +} + +// noteLinkID recognizes local links ending in note/N. Leading /, ./ and ../ +// components are all allowed; schemes and protocol-relative hosts are not. +func noteLinkID(destination string) int { + u, err := url.Parse(strings.TrimSpace(destination)) + if err != nil || u.Scheme != "" || u.Host != "" { + return 0 + } + clean := path.Clean("/" + strings.TrimSuffix(u.Path, ".html")) + parts := strings.Split(strings.Trim(clean, "/"), "/") + if len(parts) < 2 || parts[len(parts)-2] != "note" { + return 0 + } + id, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil || id < 1 { + return 0 + } + return id +} + +// render turns one note's text into HTML. A note is never worth losing to a +// renderer error, so a failure falls back to the text as written. +func (m *markdown) render(text string) template.HTML { + if strings.TrimSpace(text) == "" { + return "" + } + var b bytes.Buffer + b.Grow(len(text) + len(text)/2) + if err := m.md.Convert([]byte(text), &b); err != nil { + return template.HTML("<p>" + template.HTMLEscapeString(text) + "</p>") + } + return template.HTML(b.String()) +} + +var noteHrefRE = regexp.MustCompile(`href="([^"]+)"`) + +// renderAt canonicalizes note links for the page carrying the card. A note is +// shown at several URL depths (/notes, /code/x, /note/N, and static variants), +// so leaving its relative Markdown href untouched would make the mention work +// in only one of those places. +func (m *markdown) renderAt(text string, p *page) template.HTML { + body := m.render(text) + if body == "" || p == nil { + return body + } + out := noteHrefRE.ReplaceAllStringFunc(string(body), func(attr string) string { + destination := stdhtml.UnescapeString(attr[len(`href="`) : len(attr)-1]) + id := noteLinkID(destination) + if id == 0 { + return attr + } + if !p.Live && p.Site != nil && p.Site.NoteByID(id) == nil { + return attr + } + href := p.Href("note", strconv.Itoa(id)) + if href == "" { + return attr + } + return `href="` + template.HTMLEscapeString(href) + `"` + }) + return template.HTML(out) +} + +// noteRenderer overrides the two node kinds the stock renderer emits without +// syntax colors. Everything else it renders is fine as goldmark writes it. +type noteRenderer struct{ hl *highlighter } + +func (nr *noteRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindFencedCodeBlock, nr.renderFenced) + reg.Register(ast.KindCodeBlock, nr.renderIndented) +} + +func (nr *noteRenderer) renderFenced(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkSkipChildren, nil + } + n := node.(*ast.FencedCodeBlock) + nr.code(w, blockLines(src, n), string(n.Language(src))) + return ast.WalkSkipChildren, nil +} + +func (nr *noteRenderer) renderIndented(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkSkipChildren, nil + } + nr.code(w, blockLines(src, node), "") + return ast.WalkSkipChildren, nil +} + +// blockLines joins a block node's source lines. They are contiguous in the +// source, but only the segments are authoritative about where the block's +// indentation was stripped. +func blockLines(src []byte, n ast.Node) []byte { + lines := n.Lines() + var b bytes.Buffer + for i := 0; i < lines.Len(); i++ { + seg := lines.At(i) + b.Write(seg.Value(src)) + } + return bytes.TrimRight(b.Bytes(), "\n") +} + +// code writes one code block, syntax-painted when a grammar claims the fence's +// language and plain when none does. It emits the same <span class="s-…"> +// runs as the code view, so the stylesheet already knows how to color it; the +// <li>-per-line scaffolding does not come along, since a snippet has no line +// numbers, no coverage and no notes of its own. +func (nr *noteRenderer) code(w util.BufWriter, src []byte, lang string) { + w.WriteString(`<pre class="mdcode"`) + if lang != "" { + w.WriteString(` data-lang="`) + w.WriteString(template.HTMLEscapeString(lang)) + w.WriteByte('"') + } + w.WriteString("><code>") + + var b strings.Builder + b.Grow(len(src) + len(src)/4) + classes := nr.hl.classifyLang(lang, src) + names := []string(nil) + if nr.hl != nil { + names = nr.hl.classNames + } + for start := 0; start <= len(src); { + end := start + for end < len(src) && src[end] != '\n' { + end++ + } + emitLine(&b, src[start:end], classes[start:end], names) + if end >= len(src) { + break + } + b.WriteByte('\n') + start = end + 1 + } + w.WriteString(b.String()) + w.WriteString("</code></pre>\n") +} diff --git a/vrsite/markdown_test.go b/vrsite/markdown_test.go new file mode 100644 index 0000000..e8f1fe1 --- /dev/null +++ b/vrsite/markdown_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func testMarkdown(t *testing.T) *markdown { + t.Helper() + return newMarkdown(newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))) +} + +func TestMarkdownNotes(t *testing.T) { + m := testMarkdown(t) + cases := []struct { + name string + in string + want []string + not []string + }{ + { + name: "inline marks", + in: "`detect()` prefers **direct env** and only walks the tree as *fallback*", + want: []string{"<code>detect()</code>", "<strong>direct env</strong>", "<em>fallback</em>"}, + }, + { + name: "list after a paragraph", + in: "three classes:\n\n- persisted RPC narrowed\n- ingress bounds change", + want: []string{"<p>three classes:</p>", "<ul>", "<li>persisted RPC narrowed</li>"}, + }, + { + name: "bare url", + in: "see https://example.com/x for the rest", + want: []string{`<a href="https://example.com/x">`}, + }, + { + name: "single newlines are kept", + in: "first line\nsecond line", + want: []string{"first line<br>", "second line"}, + not: []string{"<ul>"}, + }, + // the identifiers real notes are full of must survive untouched: + // underscores are not emphasis, "4.2" is not a list, "@" is not a + // mail address + { + name: "code prose is left alone", + in: "4.2 drops stake_minimum_delegation_for_rewards and remove_simple_vote, unlike @", + want: []string{"stake_minimum_delegation_for_rewards", "remove_simple_vote", "4.2 drops"}, + not: []string{"<em>", "<ol>", "<a href"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(m.render(c.in)) + for _, w := range c.want { + if !strings.Contains(got, w) { + t.Errorf("missing %q in:\n%s", w, got) + } + } + for _, n := range c.not { + if strings.Contains(got, n) { + t.Errorf("unwanted %q in:\n%s", n, got) + } + } + }) + } +} + +func TestNoteLinksFindOnlyLocalMarkdownLinks(t *testing.T) { + p := newNoteLinkParser() + got := noteLinks(p, strings.Join([]string{ + "[root](/note/2)", + "[same target](note/2#discussion)", + "[static relative](../note/3.html?scope=all)", + "`[code](note/4)` and plain note/5 are not links", + "[remote](https://example.com/note/6)", + "[protocol relative](//example.com/note/7)", + "[reference][n]", + "[n]: ./note/8", + }, "\n\n")) + if len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 8 { + t.Errorf("note links = %v, want [2 3 8]", got) + } + + m := testMarkdown(t) + text := "[note](../note/3.html) and [remote](https://example.com/note/4)" + if body := string(m.renderAt(text, &page{Live: true, Root: "/"})); !strings.Contains(body, `href="/note/3"`) || !strings.Contains(body, `href="https://example.com/note/4"`) { + t.Errorf("live note links were not canonicalized selectively:\n%s", body) + } + if body := string(m.renderAt(text, &page{Root: "../"})); !strings.Contains(body, `href="../note/3.html"`) { + t.Errorf("static note link does not follow the page root:\n%s", body) + } +} + +// Angle brackets in a note are code, never markup: they must come through as +// text, and must not stop the markdown around them from being parsed. That +// second half is what goldmark's HTML *block* parser would break — a note +// opening with a quoted tag would render the rest of its paragraph as source. +func TestMarkdownShowsAngleBracketsAsText(t *testing.T) { + m := testMarkdown(t) + cases := []struct{ name, in string }{ + {"inline", "sysvar_cache: RwLock<SysvarCache> — hoisted out of the loop"}, + {"block, tag alone on the line", "<div>\nthe **wrapper** in [page.go](https://go.dev)"}, + {"script", "<script>alert(1)</script> is quoted from [the file](https://go.dev)"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(m.render(c.in)) + if strings.Contains(got, "<div>") || strings.Contains(got, "<script>") { + t.Errorf("markup reached the page:\n%s", got) + } + if strings.Contains(got, "raw HTML omitted") { + t.Errorf("text was dropped:\n%s", got) + } + for _, w := range []string{"<", ">"} { + if !strings.Contains(got, w) { + t.Errorf("missing %q in:\n%s", w, got) + } + } + if strings.Contains(c.in, "[") && !strings.Contains(got, `<a href="https://go.dev">`) { + t.Errorf("markdown stopped being parsed:\n%s", got) + } + }) + } +} + +// A note is written by whoever ran vr, so the renderer is the last thing +// standing between the log and the page. +func TestMarkdownDropsDangerousLinks(t *testing.T) { + m := testMarkdown(t) + for _, in := range []string{ + "[click](javascript:alert(1))", + ")", + "[click](vbscript:alert(1))", + } { + if got := string(m.render(in)); strings.Contains(got, "javascript:") || strings.Contains(got, "vbscript:") { + t.Errorf("%q rendered as %s", in, got) + } + } +} + +// A fenced block is painted by the same grammars as the code view, so a +// snippet in a note wears the reader's theme. +func TestMarkdownHighlightsFences(t *testing.T) { + m := testMarkdown(t) + got := string(m.render("```go\nfunc load() error { return nil }\n```")) + for _, w := range []string{`<pre class="mdcode" data-lang="go">`, `<span class="s-keyword">func</span>`, "</code></pre>"} { + if !strings.Contains(got, w) { + t.Errorf("missing %q in:\n%s", w, got) + } + } + // a language no grammar claims still renders, just unpainted + got = string(m.render("```brainfuck\n+[-]<>\n```")) + if !strings.Contains(got, "+[-]<>") { + t.Errorf("unclaimed fence lost its text:\n%s", got) + } + if strings.Contains(got, "<span") { + t.Errorf("unclaimed fence was painted:\n%s", got) + } +} + +// Fence languages are mostly extensions already, so the grammar table does the +// lookup; fenceAliases only has to cover the spelled-out names. Each pair here +// is a snippet the claimed grammar must find something to paint in. +func TestMarkdownFenceLanguages(t *testing.T) { + hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light")) + for _, c := range []struct{ lang, src string }{ + {"go", "func f() {}"}, {"GO", "func f() {}"}, {"golang", "func f() {}"}, + {"rs", "fn f() {}"}, {"rust", "fn f() {}"}, + {"py", "def f(): pass"}, {"python", "def f(): pass"}, {"python3", "def f(): pass"}, + {"js", "function f() {}"}, {"javascript", "function f() {}"}, {"node", "function f() {}"}, + {"jsx", "function f() {}"}, {"mjs", "function f() {}"}, + {"ts", "function f(): void {}"}, {"typescript", "function f(): void {}"}, + {"tsx", "function f(): void {}"}, + {"c", "int f(void) { return 0; }"}, + {"cpp", "int f() { return 0; }"}, {"c++", "int f() { return 0; }"}, {"h", "int f();"}, + {"json", `{"a": 1}`}, {"jsonc", `{"a": 1}`}, {"json5", `{"a": 1}`}, + {"sh", "for x in a; do echo $x; done"}, {"bash", "for x in a; do echo $x; done"}, + {"shell", "for x in a; do echo $x; done"}, {"zsh", "for x in a; do echo $x; done"}, + {"console", "for x in a; do echo $x; done"}, + } { + if !painted(hl.classifyLang(c.lang, []byte(c.src))) { + t.Errorf("fence language %q painted nothing in %q", c.lang, c.src) + } + } + for _, lang := range []string{"", "text", "brainfuck", " ", "."} { + src := []byte("func f() {}") + if got := hl.classifyLang(lang, src); len(got) != len(src) || painted(got) { + t.Errorf("classifyLang(%q) claimed a grammar it should not have", lang) + } + } +} + +func painted(classes []int16) bool { + for _, c := range classes { + if c != 0 { + return true + } + } + return false +} + +// The note card is the one place all of this has to arrive: the notes page, +// the aside on a file, and the conversation view all render it. +func TestNotePageRendersMarkdown(t *testing.T) { + sv, _, _ := testServer(t) + h := sv.handler() + + form := url.Values{"file": {"hello.txt"}, "text": { + "`cover()` widens the **mask**\n\n- one\n- two\n\n```go\nfunc f() {}\n```"}, "rev": {"@"}} + r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("POST /notes = %d: %s", w.Code, w.Body) + } + for _, want := range []string{ + `<div class="body">`, + "<code>cover()</code>", "<strong>mask</strong>", + "<li>one</li>", + `<pre class="mdcode" data-lang="go">`, `<span class="s-keyword">func</span>`, + } { + if !strings.Contains(w.Body.String(), want) { + t.Errorf("missing %q in the posted note card:\n%s", want, w.Body) + } + } + // and again off the page itself, not just the htmx fragment + if b := get(t, h, "/notes", false).Body.String(); !strings.Contains(b, "<code>cover()</code>") { + t.Error("notes page did not render the note as markdown") + } +} + +func TestMarkdownEmpty(t *testing.T) { + m := testMarkdown(t) + for _, in := range []string{"", " ", "\n\n"} { + if got := m.render(in); got != "" { + t.Errorf("render(%q) = %q, want empty", in, got) + } + } +} diff --git a/vrsite/model.go b/vrsite/model.go index 678d5ee..b580b49 100644 --- a/vrsite/model.go +++ b/vrsite/model.go @@ -35,6 +35,7 @@ type Entry struct { Pattern string `json:"pattern,omitempty"` Matches int `json:"matches,omitempty"` NoteID int `json:"note_id,omitempty"` + ReplyTo int `json:"reply_to,omitempty"` Type string `json:"type,omitempty"` Text string `json:"text,omitempty"` } @@ -52,10 +53,23 @@ type Note struct { Session, Model, Reason string File, Kind, Text, Change, Rev string Start, End int + ReplyTo int + Replies, Mentions []*Note // notes that point back to this one At time.Time When, Ago string } +// ThreadNode is the tree-shaped rendering of the note DAG. A note reached by +// more than one path is emitted fully once and then as a compact reference, so +// the focused page keeps valid DOM ids and cannot recurse through malformed +// cyclic logs. +type ThreadNode struct { + Note *Note + Via string // "reply" | "mention"; empty for the focused root + Children []*ThreadNode + Reference bool +} + type File struct { Path, Change string Traced bool @@ -150,7 +164,8 @@ func buildModel(title string, entries []Entry, only string) *Site { at := parseTime(e.Time) n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session, Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change, - Rev: e.Rev, Start: e.Start, End: e.End, Kind: e.Type, Text: e.Text, + Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo, + Kind: e.Type, Text: e.Text, At: at, When: stamp(at), Ago: ago(at)} s.Notes = append(s.Notes, n) if e.File != "" { @@ -183,6 +198,7 @@ func buildModel(title string, entries []Entry, only string) *Site { s.Order = append(s.Order, p) } sort.Strings(s.Order) + linkNotes(s.Notes) return s } @@ -243,17 +259,6 @@ func (s *Site) filesAt(paths []string, change string) []*File { return all } -// maskColor mixes the theme's agent colors for a coverage bitmask. -func (s *Site) maskColor(t *Theme, mask uint8) string { - var cols []string - for i, a := range s.Agents { - if a.Bit != 0 && mask&a.Bit != 0 { - cols = append(cols, t.Player(i)) - } - } - return mixColors(cols) -} - // masks lists every bitmask combination the colored agents can produce. func (s *Site) masks() []uint8 { n := len(s.Agents) @@ -318,6 +323,91 @@ func (s *Site) FilterNotes(f NoteFilter) []*Note { return out } +// NoteByID finds a note by the id a permalink names. Ids count over the whole +// log and the log only ever grows, so the link a reader was given goes on +// meaning the same note however the site is later scoped or rebuilt. +func (s *Site) NoteByID(id int) *Note { + for _, n := range s.Notes { + if n.ID == id { + return n + } + } + return nil +} + +// linkNotes derives the graph without adding another log operation. Replies +// are explicit reply_to edges; mentions are ordinary local Markdown links to +// /note/N (or an equivalent relative path). If a reply also links its parent, +// the explicit relationship wins and the child is not counted twice. +func linkNotes(notes []*Note) { + byID := make(map[int]*Note, len(notes)) + for _, n := range notes { + byID[n.ID] = n + } + links := newNoteLinkParser() + for _, child := range notes { + if parent := byID[child.ReplyTo]; parent != nil && parent != child { + parent.Replies = append(parent.Replies, child) + } + for _, id := range noteLinks(links, child.Text) { + parent := byID[id] + if parent == nil || parent == child || id == child.ReplyTo { + continue + } + parent.Mentions = append(parent.Mentions, child) + } + } +} + +// NoteThread expands replies and mentions together, oldest first. The global +// seen set turns a DAG into one readable Hacker News-style thread while still +// leaving a reference at every additional incoming edge. +func (s *Site) NoteThread(id int) *ThreadNode { + root := s.NoteByID(id) + if root == nil { + return nil + } + seen := map[int]bool{id: true} + var build func(*Note) *ThreadNode + build = func(n *Note) *ThreadNode { + node := &ThreadNode{Note: n} + for _, edge := range noteEdges(n) { + child := &ThreadNode{Note: edge.Note, Via: edge.Via} + if seen[edge.Note.ID] { + child.Reference = true + } else { + seen[edge.Note.ID] = true + child = build(edge.Note) + child.Via = edge.Via + } + node.Children = append(node.Children, child) + } + return node + } + return build(root) +} + +type noteEdge struct { + Note *Note + Via string +} + +func noteEdges(n *Note) []noteEdge { + seen := map[int]bool{} + edges := make([]noteEdge, 0, len(n.Replies)+len(n.Mentions)) + for _, child := range n.Replies { + seen[child.ID] = true + edges = append(edges, noteEdge{Note: child, Via: "reply"}) + } + for _, child := range n.Mentions { + if !seen[child.ID] { + edges = append(edges, noteEdge{Note: child, Via: "mention"}) + } + } + sort.SliceStable(edges, func(i, j int) bool { return edges[i].Note.ID < edges[j].Note.ID }) + return edges +} + // Facets are the distinct values the note filters can select, so the form // only ever offers choices that exist in the log. type Facets struct { diff --git a/vrsite/page.go b/vrsite/page.go index 2974eec..cca0c58 100644 --- a/vrsite/page.go +++ b/vrsite/page.go @@ -16,7 +16,7 @@ import ( type page struct { Site *Site Title string - Kind string // index | file | files | notes | jj | change | session + Kind string // index | file | files | notes | note | jj | change | session Root string // static: relative prefix; live: "/" Live bool Rev string // revision being browsed ("" = the change the log points at) @@ -40,6 +40,14 @@ type page struct { File *File Files []*File // every file at this change, for counts + // one note on its own page — the permalink. Excerpt is the code it is + // about, already rendered; ExErr says why there is none. + Note *Note + Thread *ThreadNode + Excerpt template.HTML + ExFrom, ExTo int + ExErr string + Notes []*Note Filter NoteFilter Facets *Facets @@ -105,7 +113,7 @@ func (p *page) BackHref() string { // that can be read at any change, the file tree otherwise. func (p *page) JumpPath() string { switch p.Kind { - case "index", "file", "files", "notes": + case "index", "file", "files", "notes", "note": if p.Path != "" { return p.Path } @@ -134,6 +142,8 @@ func (p *page) Href(kind string, arg ...string) string { return "/jj" case "code": return "/code/" + escPath(a) + p.params() + case "note": + return "/note/" + a case "change": return "/change/" + a case "session": @@ -154,6 +164,8 @@ func (p *page) Href(kind string, arg ...string) string { return p.Root + "tree.html" case "code": return p.Root + "code/" + a + ".html" + case "note": + return p.Root + "note/" + a + ".html" case "asset": return p.Root + a } @@ -184,6 +196,67 @@ func (p *page) ConvHref(n *Note) string { return p.Href("session", n.Session) + "?note=" + id + "#note-" + id } +// PermaHref is the note's own page: the link to hand someone else. It is the +// one link on the card that names nothing but the note, so it survives the +// change scope, the filters, and the file having moved on since. +// +// It is empty on the note's own page, which is where it already points, and in +// a static export of a different change, which has no page for it. +func (p *page) PermaHref(n *Note) string { + if p.Kind == "note" && (p.Note == nil || p.Note.ID == n.ID) { + return "" + } + if !p.Live && p.Site != nil && p.Site.NoteByID(n.ID) == nil { + return "" + } + return p.Href("note", strconv.Itoa(n.ID)) +} + +// ThreadHref opens the incoming replies and mentions for a note. The focused +// root only needs an in-page jump; every other card gets its own permalink. +func (p *page) ThreadHref(n *Note) string { + if len(n.Replies) == 0 && len(n.Mentions) == 0 { + return "" + } + if p.Kind == "note" && p.Note != nil && p.Note.ID == n.ID { + return "#discussion" + } + h := p.Href("note", strconv.Itoa(n.ID)) + if h == "" { + return "" + } + return h + "#discussion" +} + +// ReplyHref is the explicit parent link. A static export may not contain the +// parent's change, in which case it leaves the relationship as text instead of +// manufacturing a link to a page the export did not write. +func (p *page) ReplyHref(n *Note) string { + if n.ReplyTo < 1 { + return "" + } + if !p.Live && p.Site != nil && p.Site.NoteByID(n.ReplyTo) == nil { + return "" + } + return p.Href("note", strconv.Itoa(n.ReplyTo)) +} + +// HasCode reports whether this view has a page for a path. The live app can +// render any file at the change it is reading; a static export only wrote the +// ones present at the change it holds, so a note about a file that is not +// there must not offer a link into nothing. +func (p *page) HasCode(path string) bool { + if p.Live { + return path != "" + } + for _, f := range p.Files { + if f.Path == path { + return true + } + } + return false +} + // NoteHref points at the noted line, or the note list when it is unpinned. // The ?note= is what turns a jump into an arrival: the page opens in notes // focus with the card flashed and its lines marked (see app.js). @@ -204,9 +277,14 @@ func (p *page) NoteHref(n *Note) string { return h } -func newTemplates() *template.Template { +// newTemplates parses the page templates. The highlighter comes along because +// note bodies are markdown, and a fenced code block in a note is painted by +// the same grammars as the code view. +func newTemplates(hl *highlighter) *template.Template { + md := newMarkdown(hl) funcs := template.FuncMap{ "short": short, + "md": md.renderAt, "band": func(a, b uint8) bool { return b != 0 && a&b != 0 }, "lines": func(n *Note) string { if n.Start <= 0 { diff --git a/vrsite/permalink_test.go b/vrsite/permalink_test.go new file mode 100644 index 0000000..c3d572f --- /dev/null +++ b/vrsite/permalink_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestNoteByIDSpansTheWholeLog(t *testing.T) { + entries := parseLog([]byte(logLines(noteA, noteB))) + full := buildModel("test", entries, "") + if n := full.NoteByID(2); n == nil || n.Text != "entry point does too much" { + t.Fatalf("NoteByID(2) = %+v", n) + } + if n := full.NoteByID(99); n != nil { + t.Errorf("NoteByID(99) = %+v, want nil", n) + } + // ids are counted over the whole log, so a link keeps meaning the same + // note when the site is scoped to one change — that is the whole point of + // a permalink + scoped := buildModel("test", entries, "aaa") + for _, id := range []int{1, 2} { + a, b := full.NoteByID(id), scoped.NoteByID(id) + if a == nil || b == nil || a.Text != b.Text { + t.Errorf("note %d differs between scopes: %+v vs %+v", id, a, b) + } + } +} + +func TestPermaHref(t *testing.T) { + n := &Note{ID: 7} + if got := (&page{Live: true, Root: "/"}).PermaHref(n); got != "/note/7" { + t.Errorf("live permalink = %q", got) + } + // a permalink names nothing but the note: no rev, no scope, so it means + // the same thing wherever it is pasted + wide := &page{Live: true, Root: "/", Rev: "xyz", Scope: "all"} + if got := wide.PermaHref(n); got != "/note/7" { + t.Errorf("permalink from a scoped view = %q", got) + } + static := &page{Root: "", Site: &Site{Notes: []*Note{n}}} + if got := static.PermaHref(n); got != "note/7.html" { + t.Errorf("static permalink = %q", got) + } + // on the note's own page there is nowhere to go + if got := (&page{Live: true, Root: "/", Kind: "note"}).PermaHref(n); got != "" { + t.Errorf("self permalink = %q, want none", got) + } + // an export holding another change has no page to point at + empty := &page{Root: "", Site: &Site{}} + if got := empty.PermaHref(n); got != "" { + t.Errorf("permalink into an export without the note = %q, want none", got) + } +} + +func TestExcerptShowsTheNotedLinesInContext(t *testing.T) { + hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light")) + var b strings.Builder + for i := 1; i <= 60; i++ { + b.WriteString("line\n") + } + src := []byte(b.String()) + + body, from, to := excerptFor("x.txt", src, nil, hl, &Note{ID: 3, Start: 20, End: 24}) + if from != 20-excerptContext || to != 24+excerptContext { + t.Errorf("range = %d–%d, want %d–%d", from, to, 20-excerptContext, 24+excerptContext) + } + got := string(body) + // real line numbers, not the excerpt's own + if !strings.Contains(got, `id="L16"`) || !strings.Contains(got, `id="L28"`) { + t.Errorf("excerpt is not numbered from the file:\n%s", got) + } + if strings.Contains(got, `id="L15"`) || strings.Contains(got, `id="L29"`) { + t.Errorf("excerpt reaches past its context:\n%s", got) + } + // only the noted lines are marked, and they carry the note's anchor + for _, want := range []string{`<li id="L20"`, `class="noted"`, `data-note="note-3"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } + if n := strings.Count(got, "noted"); n != 5 { + t.Errorf("marked %d lines, want 5 (20–24)", n) + } + + // context is clamped to the file, never past either end + if _, from, to = excerptFor("x.txt", src, nil, hl, &Note{Start: 2, End: 2}); from != 1 { + t.Errorf("excerpt from = %d, want 1", from) + } + if _, from, to = excerptFor("x.txt", src, nil, hl, &Note{Start: 59, End: 59}); to != 60 { + t.Errorf("excerpt to = %d, want 60", to) + } + // a note pinned to no line has nothing to excerpt + if body, _, _ := excerptFor("x.txt", src, nil, hl, &Note{}); body != "" { + t.Errorf("whole-file note produced an excerpt: %s", body) + } +} + +// content-visibility: auto brings style containment with it, and a contained +// element's own counter-reset comes out as 0 whatever value it names. Every +// chunk after the first would then count from 1, so the number a line shows +// has to be set on the line itself. +func TestChunksAreNumberedFromTheirFirstLine(t *testing.T) { + hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light")) + var b strings.Builder + for i := 1; i <= chunkLines*2+10; i++ { + b.WriteString("x\n") + } + f := &File{Path: "big.txt", NoteAt: map[int]int{}} + f.render([]byte(b.String()), hl) + got := string(f.Body) + + if strings.Contains(got, "counter-reset") { + t.Error("a chunk still resets the counter on the <ol>, which containment ignores") + } + for _, ln := range []int{1, chunkLines + 1, chunkLines*2 + 1} { + want := `<li id="L` + strconv.Itoa(ln) + `" style="counter-set: ln ` + strconv.Itoa(ln) + `"` + if !strings.Contains(got, want) { + t.Errorf("chunk starting at line %d does not set its number (want %q)", ln, want) + } + } + if n := strings.Count(got, "counter-set"); n != 3 { + t.Errorf("%d lines set the counter, want 3 — one per chunk", n) + } +} + +// The permalink is the link handed to someone else, so it has to open on its +// own: the note, and the code it is about, at the change it was written at. +func TestNotePermalinkOverHTTP(t *testing.T) { + sv, _, _ := testServer(t) + h := sv.handler() + + // testServer's log holds one note, on hello.txt:2 + w := get(t, h, "/note/1", false) + if w.Code != 200 { + t.Fatalf("GET /note/1 = %d: %s", w.Code, w.Body) + } + body := w.Body.String() + for _, want := range []string{ + "second line matters", // the note + `data-kind="note"`, // and its own page, not a list + `<li id="L2"`, // the line it is about… + "two", // …with the code on it + `<li id="L1"`, // in context + "open the whole file", + } { + if !strings.Contains(body, want) { + t.Errorf("permalink page is missing %q", want) + } + } + // the card does not link to the page it is already on + if strings.Contains(body, `class="perma" href`) { + t.Error("the note links to its own page") + } + + // and the notes list offers the link in the first place + if list := get(t, h, "/notes", false).Body.String(); !strings.Contains(list, `class="perma" href="/note/1"`) { + t.Error("the notes page offers no permalink") + } + + for _, path := range []string{"/note/999", "/note/abc", "/note/0"} { + if code := get(t, h, path, false).Code; code != 404 { + t.Errorf("GET %s = %d, want 404", path, code) + } + } +} + +func TestStaticExportWritesNotePages(t *testing.T) { + sv, logPath, change := testServer(t) + extra := logLines( + `{"time":"2026-07-20T10:02:00Z","op":"note","session":"s2","agent":"claude","change":"`+change+`","note_id":1,"reply_to":1,"text":"static child"}`, + `{"time":"2026-07-20T10:03:00Z","op":"note","session":"s3","agent":"codex","change":"`+change+`","note_id":1,"text":"[static mention](note/1)"}`, + ) + if err := os.WriteFile(logPath, append(mustRead(t, logPath), extra...), 0o644); err != nil { + t.Fatal(err) + } + site, _, _ := sv.snap() + out := t.TempDir() + writeSite(site, out, sv.repo, testThemes(sv.theme, sv.light)) + + b, err := os.ReadFile(filepath.Join(out, "note", "1.html")) + if err != nil { + t.Fatalf("no page for note 1: %v", err) + } + page := string(b) + for _, want := range []string{"second line matters", `<li id="L2"`, "two", + `href="../code/hello.txt.html?note=1#L2"`, "static child", "static mention", + `class="threadnode via-reply"`, `class="threadnode via-mention"`, + `href="../note/1.html"`} { + if !strings.Contains(page, want) { + t.Errorf("exported note page is missing %q", want) + } + } + // the links out have to be relative, since an export is opened off a disk + if strings.Contains(page, `href="/`) { + t.Errorf("exported note page carries an absolute link:\n%s", page) + } + if strings.Contains(page, `class="replybox"`) { + t.Error("the static thread offers a reply form without a server") + } + notes, err := os.ReadFile(filepath.Join(out, "notes.html")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(notes), `class="perma" href="note/1.html"`) { + t.Error("the exported notes page offers no permalink") + } +} diff --git a/vrsite/render.go b/vrsite/render.go index f18c3a8..61b1a1a 100644 --- a/vrsite/render.go +++ b/vrsite/render.go @@ -58,34 +58,66 @@ func (f *File) render(src []byte, hl *highlighter) { } } } + f.Body = f.emit(src, classes, hl.classNames, 1, f.Total) +} +// emit writes lines [from..to] as <ol> chunks. from is where the chunking (and +// so the browser's content-visibility windows) starts counting, which is what +// lets an excerpt come out as one chunk rather than a slice of the file's. +func (f *File) emit(src []byte, classes []int16, names []string, from, to int) template.HTML { + if from < 1 { + from = 1 + } + if to > f.Total { + to = f.Total + } + if from > to { + return "" + } + start := 0 + for ln := 1; ln < from; ln++ { // skip to the first line asked for + start = pastEOL(src, start) + } + stop := start + for ln := from; ln <= to; ln++ { + stop = pastEOL(src, stop) + } var b strings.Builder - b.Grow(len(src) + len(src)/2 + f.Total*48) + span := stop - start + b.Grow(span + span/2 + (to-from+1)*48) var num []byte writeInt := func(n int) { num = strconv.AppendInt(num[:0], int64(n), 10); b.Write(num) } - start := 0 - for ln := 1; ln <= f.Total; ln++ { + for ln := from; ln <= to; ln++ { end := start for end < len(src) && src[end] != '\n' { end++ } - if (ln-1)%chunkLines == 0 { - if ln > 1 { + opens := (ln-from)%chunkLines == 0 + if opens { + if ln > from { b.WriteString("</ol>") } - rem := f.Total - (ln - 1) + rem := to - (ln - 1) if rem > chunkLines { rem = chunkLines } - b.WriteString(`<ol class="code" style="counter-reset: ln `) - writeInt(ln - 1) - b.WriteString(`; --h: `) + b.WriteString(`<ol class="code" style="--h: `) writeInt(rem * lineHeightPx) b.WriteString(`px">`) } b.WriteString(`<li id="L`) writeInt(ln) b.WriteByte('"') + // Where the chunk's numbering starts. It has to be counter-set on the + // first line rather than counter-reset on the <ol>, because + // content-visibility: auto brings style containment with it and a + // contained element's own counter-reset comes out as 0 no matter what + // value it names — so every chunk but the first would count from 1. + if opens { + b.WriteString(` style="counter-set: ln `) + writeInt(ln) + b.WriteByte('"') + } mask := uint8(0) if ln < len(f.Cov) { mask = f.Cov[ln] @@ -110,14 +142,64 @@ func (f *File) render(src []byte, hl *highlighter) { b.WriteByte('"') } b.WriteByte('>') - emitLine(&b, src[start:end], classes[start:end], hl.classNames) + emitLine(&b, src[start:end], classes[start:end], names) b.WriteString("</li>") start = end + 1 } - if f.Total > 0 { - b.WriteString("</ol>") + b.WriteString("</ol>") + return template.HTML(b.String()) +} + +// pastEOL is the offset just past the line starting at i. +func pastEOL(src []byte, i int) int { + for i < len(src) && src[i] != '\n' { + i++ } - f.Body = template.HTML(b.String()) + if i < len(src) { + i++ + } + return i +} + +// excerptContext is how many lines either side of a note's range its own page +// shows, so the noted lines are read in context rather than in isolation. +const excerptContext = 4 + +// excerptFor renders just the lines a note is about, the way the code view +// would: the file's real line numbers, its coverage tint, and the noted lines +// marked. This is what lets a shared permalink stand on its own — whoever +// opens it sees the note and the code it is about together, without having to +// find their way into the file first. +// +// cov comes from the model and is read-only here, so the caller can pass the +// live *File's slice rather than copying it. +func excerptFor(path string, src []byte, cov []uint8, hl *highlighter, n *Note) (body template.HTML, from, to int) { + total := bytes.Count(src, []byte{'\n'}) + if len(src) > 0 && src[len(src)-1] != '\n' { + total++ + } + end := n.End + if end < n.Start { + end = n.Start + } + if n.Start < 1 || total == 0 { + return "", 0, 0 + } + f := &File{Path: path, Cov: cov, Total: total, Notes: []*Note{n}, NoteAt: map[int]int{}} + for l := n.Start; l <= end; l++ { + f.NoteAt[l] = 0 + } + from, to = n.Start-excerptContext, end+excerptContext + if from < 1 { + from = 1 + } + if to > total { + to = total + } + if from > total { + return "", 0, 0 + } + return f.emit(src, hl.classify(path, src), hl.classNames, from, to), from, to } // emitLine writes one line's spans, merging runs of the same class across @@ -307,16 +389,45 @@ type paint struct { cls int16 } -// classify is safe for concurrent use: the shared per-language query is -// immutable, and parser and cursor are per-call. Extensions no grammar claims -// come back unclassified rather than guessed at. +// classify paints a file. Extensions no grammar claims come back unclassified +// rather than guessed at. +func (h *highlighter) classify(path string, src []byte) []int16 { + return h.classifyWith(h.byExt[strings.ToLower(filepath.Ext(path))], src) +} + +// fenceAliases maps the fence languages that are not already one of the +// extensions in grammars. Most are ("go", "rs", "py", "ts", "json", "bash"), +// so the grammar table does the bulk of the lookup on its own. +var fenceAliases = map[string]string{ + "golang": ".go", "rust": ".rs", "python": ".py", "python3": ".py", + "javascript": ".js", "node": ".js", "typescript": ".ts", + "c++": ".cc", "shell": ".sh", "zsh": ".sh", "console": ".sh", + "jsonc": ".json", "json5": ".json", +} + +// classifyLang paints a fenced code block, whose language arrives as a name +// rather than a path. Unknown or absent languages come back unclassified, the +// same as an extension no grammar claims. +func (h *highlighter) classifyLang(lang string, src []byte) []int16 { + if h == nil { + return make([]int16, len(src)) + } + name := strings.ToLower(strings.TrimSpace(lang)) + l := h.byExt["."+name] + if l == nil { + l = h.byExt[fenceAliases[name]] + } + return h.classifyWith(l, src) +} + +// classifyWith is safe for concurrent use: the shared per-language query is +// immutable, and parser and cursor are per-call. // // Matches arrive already filtered by their #eq?/#match?/#any-of? predicates — // go-tree-sitter evaluates those in QueryMatches.Next — so every capture here // is one the query really meant. -func (h *highlighter) classify(path string, src []byte) []int16 { +func (h *highlighter) classifyWith(l *language, src []byte) []int16 { classes := make([]int16, len(src)) - l := h.byExt[strings.ToLower(filepath.Ext(path))] if l == nil { return classes } diff --git a/vrsite/serve.go b/vrsite/serve.go index c6e1ef1..31217af 100644 --- a/vrsite/serve.go +++ b/vrsite/serve.go @@ -47,6 +47,7 @@ type server struct { tpl *template.Template hl *highlighter theme, light *Theme + themes *themeSet tx *transcripts mu sync.Mutex @@ -64,12 +65,16 @@ type server struct { // scopeAll is the key of the unscoped model — the whole log, every change. const scopeAll = "*" -func newServer(logPath, repo, title, user string, theme, light *Theme) *server { +func newServer(logPath, repo, title, user string, theme, light *Theme, themes *themeSet) *server { + if themes == nil { + themes = &themeSet{} + } + hl := newHighlighter(theme, light) sv := &server{logPath: logPath, repo: repo, title: title, user: user, - session: "web:" + user, theme: theme, light: light, - tpl: newTemplates(), models: map[string]*Site{}, trees: map[string][]*File{}, + session: "web:" + user, theme: theme, light: light, themes: themes, + tpl: newTemplates(hl), models: map[string]*Site{}, trees: map[string][]*File{}, rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())} - sv.hl = newHighlighter(theme, light) + sv.hl = hl sv.reload(true) return sv } @@ -332,6 +337,7 @@ func (sv *server) handler() http.Handler { mux.HandleFunc("GET /tree", sv.treeFrag) mux.HandleFunc("GET /code/{path...}", sv.codePage) mux.HandleFunc("GET /notes", sv.notesPage) + mux.HandleFunc("GET /note/{id}", sv.notePage) mux.HandleFunc("GET /notes/list", sv.notesList) mux.HandleFunc("POST /notes", sv.addNote) mux.HandleFunc("GET /activity", sv.activity) @@ -341,6 +347,8 @@ func (sv *server) handler() http.Handler { mux.HandleFunc("GET /jj/op", sv.jjOp) mux.HandleFunc("GET /change/{id}", sv.changePage) mux.HandleFunc("GET /style.css", sv.styleCSS) + mux.HandleFunc("GET /themes.css", sv.themeAsset) + mux.HandleFunc("GET /themes.js", sv.themeAsset) mux.HandleFunc("GET /app.js", sv.asset) mux.HandleFunc("GET /htmx.min.js", sv.asset) return mux @@ -406,6 +414,75 @@ func (sv *server) notesPage(w http.ResponseWriter, r *http.Request) { sv.render(w, p) } +// notePage is one note on its own — the permalink. It is looked up in the +// whole-log model, never the scoped one, so a link handed to someone else +// opens whatever change they arrive on. +// +// The page then reads at the change the note was written at, because that is +// where its line numbers mean anything; ?rev= still overrides, for a reader +// asking what those lines say now. +func (sv *server) notePage(w http.ResponseWriter, r *http.Request) { + full, _, _ := sv.snap() + n := full.NoteByID(atoiDefault(r.PathValue("id"), 0)) + if n == nil { + http.Error(w, "no note with that id in this log", http.StatusNotFound) + return + } + p, site, err := sv.newPage(r, "note", noteTitle(n)+" — "+sv.title) + if err != nil { + p.Err = err.Error() + sv.render(w, p) + return + } + p.Note, p.Thread, p.Current = n, full.NoteThread(n.ID), n.File + if p.Rev == "" && n.Change != "" && n.Change != p.ChangeID { + if row, rerr := changeBrief(sv.repo, n.Change); rerr == nil { + p.ChangeID, p.Commit, p.ChangeInfo = row.ID, row.Commit, &row + if row.ID != p.Default { + p.Rev = n.Change + } + site = sv.rescope(p) + } else { + p.ExErr = "the change this note was written at is no longer in the repository" + } + } + if n.File != "" && n.Start > 0 && p.ExErr == "" { + var cov []uint8 + if bf, ok := site.Files[n.File]; ok { + cov = bf.Cov + } + src, ferr := fileShow(sv.repo, p.Commit, n.File) + switch { + case ferr != nil: + p.ExErr = "not present at this change" + case bytes.IndexByte(src, 0) >= 0: + p.ExErr = "binary file — not rendered" + default: + p.Excerpt, p.ExFrom, p.ExTo = excerptFor(n.File, src, cov, sv.hl, n) + } + } + sv.render(w, p) +} + +// noteTitle names a note the way a browser tab and a link preview should: the +// lines it is about, or its id when it is pinned to nothing. +func noteTitle(n *Note) string { + if n.File == "" { + return "note #" + strconv.Itoa(n.ID) + } + name := n.File + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + if n.Start > 0 { + name += ":" + strconv.Itoa(n.Start) + if n.End > n.Start { + name += "-" + strconv.Itoa(n.End) + } + } + return name +} + func (sv *server) notesList(w http.ResponseWriter, r *http.Request) { p, site, _ := sv.newPage(r, "notes", "") p.Filter = filterFrom(r) @@ -444,7 +521,17 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { } // a note belongs to the change its writer was reading, which is the one // the form carries — not the working copy, which may have moved on since - _, _, def := sv.snap() + full, _, def := sv.snap() + replyText := strings.TrimSpace(r.FormValue("reply_to")) + replyTo := 0 + if replyText != "" { + var rerr error + replyTo, rerr = strconv.Atoi(replyText) + if rerr != nil || replyTo < 1 || full.NoteByID(replyTo) == nil { + http.Error(w, "reply target is not a note in this log", http.StatusBadRequest) + return + } + } rev := strings.TrimSpace(r.FormValue("rev")) target := strings.TrimSpace(r.FormValue("change")) for _, fallback := range []string{rev, def, "@"} { @@ -470,7 +557,8 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { Time: time.Now().Format(time.RFC3339), Op: "note", Session: sv.session, Agent: sv.user, Dir: sv.repo, Rev: rev, Change: change, File: strings.TrimSpace(r.FormValue("file")), - Start: start, End: end, Type: strings.TrimSpace(r.FormValue("kind")), Text: text, + Start: start, End: end, ReplyTo: replyTo, + Type: strings.TrimSpace(r.FormValue("kind")), Text: text, } sv.mu.Lock() @@ -639,6 +727,18 @@ func (sv *server) styleCSS(w http.ResponseWriter, r *http.Request) { b.WriteTo(w) } +// themeAsset serves the two files the theme picker needs. Both are written +// once at startup — the machine's themes do not change while we watch a log. +func (sv *server) themeAsset(w http.ResponseWriter, r *http.Request) { + body, ctype := sv.themes.JS, "text/javascript; charset=utf-8" + if strings.HasSuffix(r.URL.Path, ".css") { + body, ctype = sv.themes.CSS, "text/css; charset=utf-8" + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Cache-Control", "max-age=300") + w.Write(body) +} + func (sv *server) asset(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/") b, err := tfs.ReadFile("assets/" + name) @@ -651,12 +751,14 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) { w.Write(b) } -func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme) { - sv := newServer(logPath, repo, title, user, theme, light) +func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { + sv := newServer(logPath, repo, title, user, theme, light, themes) sv.tx = newTranscripts(roots) site, _, def := sv.snap() fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n", addr, len(site.Notes), len(site.Agents), repo, short(def)) + fmt.Printf("vrsite: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n", + theme.Name, light.Name, len(sv.themes.List), sv.themes.count("zed"), sv.themes.count("helix")) fmt.Printf("vrsite: comments are appended to %s; the repo is never written to\n", logPath) fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", ")) if err := http.ListenAndServe(addr, sv.handler()); err != nil { diff --git a/vrsite/static.go b/vrsite/static.go index 7d31b1d..b299975 100644 --- a/vrsite/static.go +++ b/vrsite/static.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "time" @@ -31,7 +32,7 @@ func run(dir string, name string, args ...string) []byte { return out } -func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) { +func buildSite(logPath, repoAbs, out, title string, theme, light *Theme, themes *themeSet) { start := time.Now() entries := readLog(logPath) ranked := buildModel(title, entries, "").Ranked @@ -80,7 +81,7 @@ func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) { fatal("tar: %v", err) } - writeSite(site, out, tmp) + writeSite(site, out, tmp, themes) stubs := 0 for _, f := range site.All { if f.Stub != "" { @@ -92,8 +93,12 @@ func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) { time.Since(start).Round(time.Millisecond), out) } -func writeSite(site *Site, out, srcDir string) { - tpl := newTemplates() +func writeSite(site *Site, out, srcDir string, themes *themeSet) { + if themes == nil { + themes = &themeSet{} + } + hl := newHighlighter(site.Theme, site.Light) + tpl := newTemplates(hl) if err := os.MkdirAll(out, 0o755); err != nil { fatal("%v", err) } @@ -113,7 +118,6 @@ func writeSite(site *Site, out, srcDir string) { } // phase A: content — coverage percentages must exist before any tree // or index below is written - hl := newHighlighter(site.Theme, site.Light) each(func(f *File) { src, err := os.ReadFile(filepath.Join(srcDir, f.Path)) switch { @@ -142,6 +146,13 @@ func writeSite(site *Site, out, srcDir string) { fatal("%v", err) } } + // the picker travels with the export: a <script src>/<link> pair loads + // from the filesystem, which is why the menu is a script and not JSON + for name, b := range map[string][]byte{"themes.css": themes.CSS, "themes.js": themes.JS} { + if err := os.WriteFile(filepath.Join(out, name), b, 0o644); err != nil { + fatal("%v", err) + } + } // shared tree fragment: the only nav tree, fetched by every page; "@@" is // replaced with the page root by app.js, since the fragment is fetched @@ -196,6 +207,35 @@ func writeSite(site *Site, out, srcDir string) { render(dst, &page{Kind: "file", Title: f.Path + " — " + site.Title, Root: strings.Repeat("../", depth), Current: f.Path, File: f}) }) + + // phase C: a page per note, so a permalink handed to someone else opens + // in the export exactly as it does in the live app. The source is read + // again rather than kept from phase A: an export is a batch job, and the + // alternative is holding every file in memory for the sake of a few + // hundred excerpts. + if err := os.MkdirAll(filepath.Join(out, "note"), 0o755); err != nil { + fatal("%v", err) + } + for _, n := range site.Notes { + p := &page{Kind: "note", Title: noteTitle(n) + " — " + site.Title, + Root: "../", Current: n.File, Note: n, Thread: site.NoteThread(n.ID)} + if n.File != "" && n.Start > 0 { + src, err := os.ReadFile(filepath.Join(srcDir, n.File)) + switch { + case err != nil: + p.ExErr = "not present in the archived commit" + case bytes.IndexByte(src, 0) >= 0: + p.ExErr = "binary file — not rendered" + default: + var cov []uint8 + if f, ok := site.Files[n.File]; ok { + cov = f.Cov + } + p.Excerpt, p.ExFrom, p.ExTo = excerptFor(n.File, src, cov, hl, n) + } + } + render(filepath.Join(out, "note", strconv.Itoa(n.ID)+".html"), p) + } } func mustCreate(path string) *os.File { diff --git a/vrsite/templates/frag-note.tmpl b/vrsite/templates/frag-note.tmpl index 0c3182c..3b04e11 100644 --- a/vrsite/templates/frag-note.tmpl +++ b/vrsite/templates/frag-note.tmpl @@ -1,19 +1,26 @@ {{define "note"}}{{$n := .N}}{{$p := .P}}{{$away := and $n.Change $p.ChangeID (ne $n.Change $p.ChangeID)}} <article class="note am{{$n.Agent.Bit}}{{if $away}} away{{end}}" id="note-{{$n.ID}}" - data-agent="{{$n.Agent.Name}}" data-model="{{$n.Model}}" data-session="{{$n.Session}}" - data-kind="{{$n.Kind}}" data-file="{{$n.File}}" data-date="{{if not $n.At.IsZero}}{{$n.At.Format "2006-01-02"}}{{end}}" - data-start="{{$n.Start}}" data-end="{{$n.End}}"> + data-agent="{{$n.Agent.Name}}" data-model="{{$n.Model}}" data-session="{{$n.Session}}" + data-kind="{{$n.Kind}}" data-file="{{$n.File}}" data-date="{{if not $n.At.IsZero}}{{$n.At.Format "2006-01-02"}}{{end}}" + data-start="{{$n.Start}}" data-end="{{$n.End}}" data-reply-to="{{$n.ReplyTo}}"> <header> {{if $n.File}}<a href="{{$p.NoteHref $n}}" title="{{$n.File}}">{{base $n.File}}{{lines $n}}</a>{{end}} {{if $n.Kind}}<em>{{$n.Kind}}</em>{{end}} <b class="who">{{$n.Agent.Short}}</b> </header> - <p>{{$n.Text}}</p> + <div class="body">{{md $n.Text $p}}</div> <footer class="meta"> + {{with $p.PermaHref $n}}<a class="perma" href="{{.}}" + title="permalink — this note on its own page, with the code it is about">#{{$n.ID}}</a> + {{else}}<span class="perma self">#{{$n.ID}}</span>{{end}} {{if not $n.At.IsZero}}<time datetime="{{$n.At.Format "2006-01-02T15:04:05Z07:00"}}" title="{{$n.Ago}}">{{$n.When}}</time>{{end}} {{if $n.Model}}<span class="model">{{$n.Model}}{{if $n.Reason}}/{{$n.Reason}}{{end}}</span>{{end}} - {{if $n.Session}}<code class="sess" title="{{$n.Session}}">{{short $n.Session}}</code>{{end}} - {{with $p.ConvHref $n}}<a class="conv" href="{{.}}" title="open the conversation this note was written in">conversation</a>{{end}} + {{if $n.Session}}<code class="sess" title="{{$n.Session}}">{{short $n.Session}}</code>{{end}} + {{if $n.ReplyTo}}{{with $p.ReplyHref $n}}<a class="parent" href="{{.}}" + title="the note this explicitly replies to">reply to #{{$n.ReplyTo}}</a>{{else}}<span class="parent">reply to #{{$n.ReplyTo}}</span>{{end}}{{end}} + {{with $p.ThreadHref $n}}<a class="relations" href="{{.}}" title="notes replying to or mentioning this note"> + {{if $n.Replies}}↳ {{len $n.Replies}} repl{{if eq (len $n.Replies) 1}}y{{else}}ies{{end}}{{end}}{{if and $n.Replies $n.Mentions}} · {{end}}{{if $n.Mentions}}↶ {{len $n.Mentions}} mention{{if ne (len $n.Mentions) 1}}s{{end}}{{end}}</a>{{end}} + {{with $p.ConvHref $n}}<a class="conv" href="{{.}}" title="open the conversation this note was written in">conversation</a>{{end}} {{if $n.Change}} {{if $p.Live}}<a class="atchg{{if $away}} away{{end}}" href="{{$p.Href "change" $n.Change}}" title="{{if $away}}written at another change{{else}}written at this change{{end}}"><code>{{short $n.Change}}</code></a> @@ -30,6 +37,44 @@ {{define "note-added"}}{{if .File}}<span id="notecount" hx-swap-oob="true">{{len .File.Notes}}</span>{{end}}{{range .Notes}}{{template "note" (dict "N" . "P" $)}}{{end}}{{end}} +{{/* A child-note form appears below every full card in a focused thread. It + deliberately uses a normal POST: one append and one redirect rebuild the + entire DAG from the log, including shared mention paths. */}} +{{define "replyform"}}{{$n := .N}}{{$p := .P}} +<details class="replybox"> + <summary>reply to #{{$n.ID}}</summary> + <form class="notform replyform" method="post" action="{{$p.Href "notes"}}"> + <input type="hidden" name="reply_to" value="{{$n.ID}}"> + <input type="hidden" name="rev" value="{{$p.Rev}}"> + <input type="hidden" name="change" value="{{$p.ChangeID}}"> + <input type="hidden" name="scope" value="all"> + <input type="hidden" name="back" value="{{$p.Href "note" (printf "%d" $p.Note.ID)}}#discussion"> + <textarea name="text" rows="2" placeholder="reply… (⌘/ctrl+enter to post)" aria-label="reply to note {{$n.ID}}" required></textarea> + <div class="row end"> + <span class="meta">as <b>{{$p.User}}</b> at <code title="{{$p.ChangeID}}">{{short $p.ChangeID}}</code> → the log</span> + <button type="submit">reply</button> + </div> + </form> +</details> +{{end}} + +{{/* Recursive at arbitrary depth. A DAG join/cycle is a reference rather than + a second card, which keeps ids unique and recursion finite. */}} +{{define "threadnode"}}{{$x := .Node}}{{$p := .P}} +<div class="threadnode via-{{$x.Via}}"> + <div class="threadedge" aria-label="{{$x.Via}}"><span>{{if eq $x.Via "reply"}}↳{{else}}↶{{end}}</span></div> + <div class="threadcontent"> + {{if $x.Reference}} + <p class="threadref meta">{{$x.Via}} also reaches <a href="{{$p.Href "note" (printf "%d" $x.Note.ID)}}">note #{{$x.Note.ID}}</a></p> + {{else}} + {{template "note" (dict "N" $x.Note "P" $p)}} + {{if $p.Live}}{{template "replyform" (dict "N" $x.Note "P" $p)}}{{end}} + {{if $x.Children}}<div class="threadchildren">{{range $x.Children}}{{template "threadnode" (dict "Node" . "P" $p)}}{{end}}</div>{{end}} + {{end}} + </div> +</div> +{{end}} + {{/* the add-a-comment form; live pages only, since it writes to the log */}} {{define "noteform"}} <form class="notform" hx-post="{{.Href "notes"}}" hx-target="#notelist" hx-swap="afterbegin" diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl index 9bc9ddd..268b5b6 100644 --- a/vrsite/templates/layout.tmpl +++ b/vrsite/templates/layout.tmpl @@ -4,12 +4,20 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{.Title}}</title> +<link rel="stylesheet" href="{{.Href "asset" "style.css"}}"> <script> +// the theme is worn before the first paint. A theme that is not one of the +// two the site was built with lives in themes.css, so pull that in too — +// from here, after style.css, so its :root block is the one that wins. {const d = document.documentElement, t = localStorage.getItem("vrsite-theme"); if (t && t !== "system") d.dataset.theme = t; + if (t && !/^(system|dark|light)$/.test(t)) { + const l = document.createElement("link"); + l.rel = "stylesheet"; l.id = "themescss"; l.href = "{{.Href "asset" "themes.css"}}"; + document.head.append(l); + } d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace";} </script> -<link rel="stylesheet" href="{{.Href "asset" "style.css"}}"> {{if .Live}}<script src="{{.Href "asset" "htmx.min.js"}}"></script>{{end}} <script src="{{.Href "asset" "app.js"}}" defer></script> </head> @@ -35,11 +43,20 @@ <a href="{{.Href "files"}}">files</a>{{if .Live}} · <a href="{{.Href "jj"}}">jj</a>{{end}} </span> - <select id="themesel" aria-label="color scheme"> - <option value="system">system</option> - <option value="dark">dark</option> - <option value="light">light</option> - </select> + <div class="themepick" id="themepick" + data-js="{{.Href "asset" "themes.js"}}" data-css="{{.Href "asset" "themes.css"}}" + data-dark="{{.Site.Theme.Name}}" data-light="{{.Site.Light.Name}}" + data-darkid="{{.Site.Theme.ID}}" data-lightid="{{.Site.Light.ID}}"> + <button type="button" id="themebtn" aria-haspopup="listbox" aria-expanded="false" + title="theme — every one installed here (T)">theme</button> + <div class="thememenu" id="thememenu" hidden> + <input id="themefind" type="search" placeholder="filter themes…" aria-label="filter themes" + role="combobox" aria-controls="themelist" aria-autocomplete="list" + autocomplete="off" spellcheck="false"> + <ul id="themelist" role="listbox" aria-label="themes"></ul> + <p class="meta hint">↑↓ preview · enter keeps · esc puts it back</p> + </div> + </div> </footer> </nav> @@ -47,6 +64,7 @@ {{if .Err}}<p class="err">{{.Err}}</p>{{end}} {{if eq .Kind "file"}}{{template "page-file" .}} {{else if eq .Kind "notes"}}{{template "page-notes" .}} +{{else if eq .Kind "note"}}{{template "page-note" .}} {{else if eq .Kind "files"}}{{template "page-files" .}} {{else if eq .Kind "jj"}}{{template "page-jj" .}} {{else if eq .Kind "change"}}{{template "page-change" .}} diff --git a/vrsite/templates/page-note.tmpl b/vrsite/templates/page-note.tmpl new file mode 100644 index 0000000..8cd0e64 --- /dev/null +++ b/vrsite/templates/page-note.tmpl @@ -0,0 +1,54 @@ +{{/* One note, on its own — the page a shared permalink opens. + + It has to stand up for a reader who has never seen this site: what was + said, about which lines of which file, at which change, and the code + itself, right there. Everything else on the page is a way further in. */}} +{{define "page-note"}} +{{$n := .Note}} +<header class="filehead"> + <h1>{{if $n.File}}{{base $n.File}}<span class="lns">{{lines $n}}</span>{{else}}Note{{end}}</h1> + <p class="meta"> + note <b>#{{$n.ID}}</b> by <b>{{$n.Agent.Short}}</b>{{if $n.Kind}} · <em class="kind">{{$n.Kind}}</em>{{end}} + {{if not $n.At.IsZero}} · <time datetime="{{$n.At.Format "2006-01-02T15:04:05Z07:00"}}">{{$n.When}}</time> + <span class="ago">({{$n.Ago}})</span>{{end}} + {{if $n.Change}} · read at + {{if .Live}}<a href="{{.Href "change" $n.Change}}"><code>{{short $n.Change}}</code></a> + {{else}}<code title="{{$n.Change}}">{{short $n.Change}}</code>{{end}} + {{end}} + </p> + {{if ne $n.File (base $n.File)}}<p class="meta"><code>{{$n.File}}</code></p>{{end}} +</header> + +<section class="notepage"> + <div class="onenote">{{template "note" (dict "N" $n "P" $)}}</div> + + {{$open := .HasCode $n.File}} + {{if .Excerpt}} + <p class="meta exhead">lines {{.ExFrom}}–{{.ExTo}} of <code>{{$n.File}}</code>{{if $open}} · + <a href="{{.NoteHref $n}}">open the whole file →</a>{{end}}</p> + <div class="codewrap excerpt">{{.Excerpt}}</div> + {{else if .ExErr}} + <p class="meta stub">{{.ExErr}}</p> + {{else if $n.File}} + <p class="meta hint">this note is about the whole file{{if $open}} — + <a href="{{.NoteHref $n}}">open <code>{{base $n.File}}</code> →</a>{{end}}</p> + {{else}} + <p class="meta hint">this note is not pinned to a file</p> + {{end}} + + {{if .Live}}{{template "replyform" (dict "N" $n "P" $)}}{{end}} + + <section class="discussion" id="discussion"> + <h2>Replies & mentions + <span class="meta">{{len $n.Replies}} repl{{if eq (len $n.Replies) 1}}y{{else}}ies{{end}} · {{len $n.Mentions}} mention{{if ne (len $n.Mentions) 1}}s{{end}}</span> + </h2> + {{if .Thread.Children}} + <div class="threadchildren rootchildren">{{range .Thread.Children}}{{template "threadnode" (dict "Node" . "P" $)}}{{end}}</div> + {{else}} + <p class="meta hint">no notes reply to or mention this one yet</p> + {{end}} + </section> + + <p class="meta more"><a href="{{.Href "notes"}}">← every note in the log</a></p> +</section> +{{end}} diff --git a/vrsite/theme.go b/vrsite/theme.go index fc63de1..91eedbe 100644 --- a/vrsite/theme.go +++ b/vrsite/theme.go @@ -1,12 +1,18 @@ // theme.go — Zed theme JSON in, CSS custom properties out. Per-agent colors // come from the theme's player (collaborator) palette. +// +// The two themes named on the command line are what the site is built with; +// themes.go finds the rest of this machine's themes for the picker, and they +// arrive here as the same *Theme. package main import ( "encoding/json" + "errors" "fmt" "os" - "path/filepath" + "sort" + "strings" ) type SyntaxStyle struct { @@ -16,6 +22,7 @@ type SyntaxStyle struct { } type Theme struct { + ID, Source string // "zed/one-dark", "zed" | "helix" Name, Appearance string Bg, Fg, Panel, Border, LineNo, Muted, Accent string Note, Hover string @@ -26,22 +33,16 @@ type Theme struct { // defaultTheme finds a zed theme file containing a theme of the wanted // appearance; "" means fall back to the embedded One family. func defaultTheme(appearance string) string { - home, _ := os.UserHomeDir() - m, _ := filepath.Glob(filepath.Join(home, ".config", "zed", "themes", "*.json")) - for _, path := range m { + for _, path := range globAll(zedThemeGlobs()) { b, err := os.ReadFile(path) if err != nil { continue } - var family struct { - Themes []struct { - Appearance string `json:"appearance"` - } `json:"themes"` - } - if json.Unmarshal(b, &family) != nil { + fam, err := parseZedFamily(b) + if err != nil { continue } - for _, t := range family.Themes { + for _, t := range fam { if t.Appearance == appearance { return path } @@ -61,6 +62,31 @@ func loadTheme(path, name, appearance string) *Theme { if err != nil { fatal("%v", err) } + fam, err := parseZedFamily(b) + if err != nil { + fatal("bad zed theme %s: %v", path, err) + } + t := fam[0] + if name != "" { + for _, c := range fam { + if c.Name == name { + t = c + } + } + } else { + for _, c := range fam { + if c.Appearance == appearance { + t = c + break + } + } + } + return t +} + +// parseZedFamily reads a zed theme file: one file is a family, and every theme +// in it is one the picker can offer. +func parseZedFamily(b []byte) ([]*Theme, error) { var family struct { Name string `json:"name"` Themes []struct { @@ -69,76 +95,168 @@ func loadTheme(path, name, appearance string) *Theme { Style map[string]json.RawMessage `json:"style"` } `json:"themes"` } - if err := json.Unmarshal(b, &family); err != nil || len(family.Themes) == 0 { - fatal("bad zed theme %s: %v", path, err) + if err := json.Unmarshal(b, &family); err != nil { + return nil, err } - th := family.Themes[0] - if name != "" { - for _, t := range family.Themes { - if t.Name == name { - th = t + if len(family.Themes) == 0 { + return nil, errors.New("no themes in it") + } + var out []*Theme + for _, th := range family.Themes { + get := func(keys ...string) string { + for _, k := range keys { + var s string + if raw, ok := th.Style[k]; ok && json.Unmarshal(raw, &s) == nil && okColor(s) != "" { + return s + } } + return "" } - } else { - for _, t := range family.Themes { - if t.Appearance == appearance { - th = t - break + t := &Theme{ID: "zed/" + slug(th.Name), Source: "zed", + Name: th.Name, Appearance: th.Appearance, + Bg: get("editor.background", "background"), + Fg: get("editor.foreground", "text"), + Panel: get("panel.background", "surface.background", "background"), + Border: get("border", "border.variant"), + LineNo: get("editor.line_number"), + Muted: get("text.muted", "text.disabled"), + Accent: get("text.accent", "info"), + Note: get("warning", "conflict", "modified"), + Hover: get("element.hover", "ghost_element.hover"), + } + json.Unmarshal(th.Style["syntax"], &t.Syntax) + for k, s := range t.Syntax { // a theme in the wild does drop the odd "#" + if okColor(s.Color) == "" { + delete(t.Syntax, k) } } + t.fill() + var players []struct { + Cursor string `json:"cursor"` + } + json.Unmarshal(th.Style["players"], &players) + var cursors []string + for _, p := range players { + cursors = append(cursors, p.Cursor) + } + t.setPlayers(cursors, t.syntaxColors()) + out = append(out, t) } - get := func(keys ...string) string { - for _, k := range keys { - var s string - if raw, ok := th.Style[k]; ok && json.Unmarshal(raw, &s) == nil && s != "" { - return s + return out, nil +} + +// setPlayers picks the per-agent colors out of the theme's own palette. A zed +// theme has one meant for exactly this — the collaborator cursors — and it is +// asked first; when it runs short, or the theme is a helix one with no such +// palette at all, the code's own colors stand in, since those are what the +// reader is already telling apart on the page. +// +// Within each tier, colors that vanish against fg/bg, collide with the note +// color, or sit on top of one already taken are dropped, and the rest ordered +// greedily for mutual distance, so the first few agents contrast most. +func (t *Theme) setPlayers(tiers ...[]string) { + t.Players = nil + taken := func(c string, of []string) bool { + for _, p := range of { + if colorDist(c, p) <= 60 { + return true } } - return "" - } - t := &Theme{Name: th.Name, Appearance: th.Appearance, - Bg: get("editor.background", "background"), - Fg: get("editor.foreground", "text"), - Panel: get("panel.background", "surface.background", "background"), - Border: get("border", "border.variant"), - LineNo: get("editor.line_number"), - Muted: get("text.muted", "text.disabled"), - Accent: get("text.accent", "info"), - Note: get("warning", "conflict", "modified"), - Hover: get("element.hover", "ghost_element.hover"), - } - json.Unmarshal(th.Style["syntax"], &t.Syntax) - var players []struct { - Cursor string `json:"cursor"` + return false } - json.Unmarshal(th.Style["players"], &players) - // agent colors: drop player colors that vanish against fg/bg or collide - // with the note color, then order greedily for mutual distance so the - // first few agents contrast most - var usable []string - for _, p := range players { - c := hex6(p.Cursor) - if c != "" && colorDist(c, hex6(t.Fg)) > 60 && colorDist(c, hex6(t.Bg)) > 60 && colorDist(c, hex6(t.Note)) > 60 { + for _, tier := range tiers { + var usable []string + for _, c := range tier { + c = hex6(c) + if len(c) != 7 || taken(c, usable) || taken(c, t.Players) || + colorDist(c, hex6(t.Fg)) <= 60 || colorDist(c, hex6(t.Bg)) <= 60 || + colorDist(c, hex6(t.Note)) <= 60 { + continue + } usable = append(usable, c) } - } - for len(usable) > 0 { - best, bestScore := 0, -1 - for i, c := range usable { - score := 1 << 24 // first pick: keep theme order - for _, p := range t.Players { - if d := colorDist(c, p); d < score { - score = d + for len(usable) > 0 && len(t.Players) < maxAgents { + best, bestScore := 0, -1 + for i, c := range usable { + score := 1 << 24 // first pick of the first tier: keep theme order + for _, p := range t.Players { + if d := colorDist(c, p); d < score { + score = d + } + } + if score > bestScore { + best, bestScore = i, score } } - if score > bestScore { - best, bestScore = i, score + t.Players = append(t.Players, usable[best]) + usable = append(usable[:best], usable[best+1:]...) + } + } +} + +// fill supplies whatever a theme does not say. Editors leave gaps a web page +// cannot — a zed theme need not name a muted text color, a helix theme has no +// notion of a panel — so every gap is closed with a shade mixed from the +// theme's own background and foreground, or with a color out of its syntax. +// A theme with three colors in it still dresses a whole page. +func (t *Theme) fill() { + bg, fg := hex6(t.Bg), t.Fg + // panel and hover are backgrounds the page writes on, so a theme that + // offers one the text would be lost in does not get to keep it + t.Panel = first(readable(t.Panel, fg, ""), blend(bg, fg, 0.05)) + t.Hover = first(readable(t.Hover, fg, ""), blend(bg, fg, 0.12)) + t.Border = first(t.Border, blend(bg, fg, 0.2)) + t.LineNo = first(t.LineNo, blend(bg, fg, 0.4)) + t.Muted = first(readable(t.Muted, t.Panel, ""), blend(bg, fg, 0.55)) + t.Accent = first(t.Accent, t.syn("function"), t.syn("keyword"), t.syn("type"), fg) + t.Note = first(t.Note, t.syn("warning"), t.syn("number"), t.Accent) +} + +// syn is the theme's color for a syntax key, resolved along its dots. +func (t *Theme) syn(key string) string { + if k := resolveKey(t, key); k != "" { + return t.Syntax[k].Color + } + return "" +} + +// syntaxColors are the theme's own code colors, in the order they stand out +// from one another: what a theme offers when its palette runs short. +func (t *Theme) syntaxColors() []string { + var out []string + for _, k := range []string{"keyword", "function", "string", "type", "constant", + "variable.parameter", "attribute", "constructor", "number", "tag", "property", + "label", "namespace", "operator", "boolean"} { + if c := t.syn(k); c != "" { + out = append(out, c) + } + } + return out +} + +// readable keeps a color only if text in `on` can be seen against it. +func readable(c, on, fallback string) string { + if c == "" || colorDist(hex6(c), hex6(on)) < 2000 { + return fallback + } + return c +} + +// syntaxKeys is every syntax key the site paints, and so every one a theme is +// asked to fill: the union of what the two built-with themes name. +func syntaxKeys(themes ...*Theme) []string { + var keys []string + seen := map[string]bool{} + for _, t := range themes { + for k := range t.Syntax { + if !seen[k] { + seen[k] = true + keys = append(keys, k) } } - t.Players = append(t.Players, usable[best]) - usable = append(usable[:best], usable[best+1:]...) } - return t + sort.Strings(keys) + return keys } func colorDist(a, b string) int { @@ -161,34 +279,63 @@ func (t *Theme) Player(i int) string { return fallbackPalette[i%len(fallbackPalette)] } +// okColor keeps a color only if it is one a browser will take: #rgb, #rrggbb +// or #rrggbbaa. Whatever else a theme file says is treated as unsaid, and +// fill() supplies it. +func okColor(c string) string { + if len(c) != 4 && len(c) != 7 && len(c) != 9 || c[0] != '#' { + return "" + } + for _, r := range c[1:] { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') { + return "" + } + } + return c +} + // hex6 strips an alpha channel from #rrggbbaa. func hex6(c string) string { if len(c) == 9 && c[0] == '#' { return c[:7] } + if len(c) == 4 && c[0] == '#' { // #rgb + return "#" + string([]byte{c[1], c[1], c[2], c[2], c[3], c[3]}) + } return c } -func mixColors(cols []string) string { - if len(cols) == 1 { - return cols[0] +func rgb(c string) (r, g, b int, ok bool) { + c = hex6(c) + if len(c) != 7 || c[0] != '#' { + return 0, 0, 0, false } - var r, g, b int - n := 0 - for _, c := range cols { - c = hex6(c) - if len(c) != 7 { - continue - } - var cr, cg, cb int - fmt.Sscanf(c[1:], "%02x%02x%02x", &cr, &cg, &cb) - r += cr - g += cg - b += cb - n++ + if n, _ := fmt.Sscanf(c[1:], "%02x%02x%02x", &r, &g, &b); n != 3 { + return 0, 0, 0, false + } + return r, g, b, true +} + +// lum is perceived brightness, 0..1 — enough to tell a light theme from a +// dark one when the theme does not say which it is. +func lum(c string) float64 { + r, g, b, ok := rgb(c) + if !ok { + return 0 } - if n == 0 { - return "#888888" + return (0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)) / 255 +} + +// blend mixes frac of b into a — for the shades a theme leaves unsaid. +func blend(a, b string, frac float64) string { + ar, ag, ab, ok := rgb(a) + br, bg, bb, ok2 := rgb(b) + if !ok || !ok2 { + return a } - return fmt.Sprintf("#%02x%02x%02x", r/n, g/n, b/n) + mix := func(x, y int) int { return int(float64(x) + (float64(y)-float64(x))*frac) } + return fmt.Sprintf("#%02x%02x%02x", mix(ar, br), mix(ag, bg), mix(ab, bb)) } + +// cssKey is a syntax key as it appears in class and property names. +func cssKey(k string) string { return strings.ReplaceAll(k, ".", "-") } diff --git a/vrsite/themes.go b/vrsite/themes.go new file mode 100644 index 0000000..f185fab --- /dev/null +++ b/vrsite/themes.go @@ -0,0 +1,445 @@ +// themes.go — the themes this machine already has. Zed keeps families of them +// in JSON, Helix keeps one TOML each with a palette and single inheritance; +// both are read into the same *Theme, so the browser can wear either. +// +// Discovery is best-effort: a theme that will not parse is skipped, never +// fatal. What the site is built with stays what -theme and -light-theme say — +// these only widen what the picker can offer. +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +// themeSet is those themes plus the two files the browser switches with: one +// CSS block per theme, and the list the picker draws from. +type themeSet struct { + List []*Theme + CSS []byte // themes.css — one :root[data-theme="…"] block per theme + JS []byte // themes.js — window.VRSITE_THEMES, the picker's menu +} + +func newThemeSet(dark, light *Theme) *themeSet { + return themesFrom(discoverThemes(), dark, light) +} + +// themesFrom writes the two files for a given list of themes. The keys the +// site paints with come from the themes it was built with, since those are the +// ones the stylesheet has classes for. +func themesFrom(list []*Theme, dark, light *Theme) *themeSet { + ts := &themeSet{List: list} + keys := syntaxKeys(dark, light) + var css, js bytes.Buffer + writeThemesCSS(&css, ts.List, keys) + writeThemesJS(&js, ts.List) + ts.CSS, ts.JS = css.Bytes(), js.Bytes() + return ts +} + +func (ts *themeSet) count(source string) int { + n := 0 + for _, t := range ts.List { + if t.Source == source { + n++ + } + } + return n +} + +// discoverThemes reads every theme both editors can see, user directories +// first so a theme you installed wins the id over one that merely ships. +func discoverThemes() []*Theme { + var out []*Theme + seen := map[string]bool{} + add := func(t *Theme) { + if t == nil || t.ID == "" || t.Bg == "" || t.Fg == "" || seen[t.ID] { + return + } + seen[t.ID] = true + out = append(out, t) + } + for _, path := range globAll(zedThemeGlobs()) { + b, err := os.ReadFile(path) + if err != nil { + continue + } + fam, err := parseZedFamily(b) + if err != nil { + continue + } + for _, t := range fam { + add(t) + } + } + dirs := helixThemeDirs() + for _, dir := range dirs { + for _, path := range globAll([]string{filepath.Join(dir, "*.toml")}) { + add(loadHelixTheme(path, dirs)) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Source != out[j].Source { + return out[i].Source < out[j].Source + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} + +func globAll(pats []string) []string { + var out []string + for _, p := range pats { + m, _ := filepath.Glob(p) + sort.Strings(m) + out = append(out, m...) + } + return out +} + +func configHome() string { + if d := os.Getenv("XDG_CONFIG_HOME"); d != "" { + return d + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} + +func dataHome() string { + if d := os.Getenv("XDG_DATA_HOME"); d != "" { + return d + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share") +} + +// zedThemeGlobs: themes you dropped in yourself, then the ones extensions +// brought. Zed's own built-ins live inside the binary, so they are not here. +func zedThemeGlobs() []string { + home, _ := os.UserHomeDir() + mac := filepath.Join(home, "Library", "Application Support", "Zed") + return []string{ + filepath.Join(configHome(), "zed", "themes", "*.json"), + filepath.Join(mac, "themes", "*.json"), + filepath.Join(dataHome(), "zed", "extensions", "installed", "*", "themes", "*.json"), + filepath.Join(mac, "extensions", "installed", "*", "themes", "*.json"), + } +} + +// helixThemeDirs: yours first, then whichever runtime the installed hx uses — +// found the way helix itself looks, by walking out from the binary. +func helixThemeDirs() []string { + home, _ := os.UserHomeDir() + dirs := []string{filepath.Join(configHome(), "helix", "themes")} + var runtimes []string + if r := os.Getenv("HELIX_RUNTIME"); r != "" { + runtimes = append(runtimes, r) + } + runtimes = append(runtimes, + filepath.Join(configHome(), "helix", "runtime"), + filepath.Join(dataHome(), "helix", "runtime")) + if bin, err := exec.LookPath("hx"); err == nil { + if bin, err = filepath.EvalSymlinks(bin); err == nil { + dir := filepath.Dir(bin) + runtimes = append(runtimes, + filepath.Join(dir, "runtime"), + filepath.Join(filepath.Dir(dir), "lib", "helix", "runtime"), + filepath.Join(filepath.Dir(dir), "share", "helix", "runtime")) + } + } + runtimes = append(runtimes, + "/usr/lib/helix/runtime", "/usr/share/helix/runtime", + "/usr/local/lib/helix/runtime", "/opt/homebrew/opt/helix/libexec/runtime", + filepath.Join(home, ".cargo", "share", "helix", "runtime")) + seen := map[string]bool{} + for _, r := range runtimes { + d := filepath.Join(r, "themes") + if seen[d] { + continue + } + seen[d] = true + if fi, err := os.Stat(d); err == nil && fi.IsDir() { + dirs = append(dirs, d) + } + } + return dirs +} + +// ── helix ───────────────────────────────────────────────────────────────── +// +// A helix theme is scope -> style, with colors named out of a [palette] at the +// bottom, and may inherit one other theme by name. Inheritance merges both +// halves before anything resolves, which is what lets a child theme restate +// the palette alone (catppuccin_latte is catppuccin_mocha with new colors). + +type helixStyle struct { + fg, bg string // palette names, hex, or one of the sixteen terminal names + italic bool + bold bool +} + +type helixRaw struct { + scopes map[string]helixStyle + palette map[string]string +} + +func loadHelixTheme(path string, dirs []string) *Theme { + raw := readHelixRaw(path, dirs, 0) + if raw == nil { + return nil + } + name := strings.TrimSuffix(filepath.Base(path), ".toml") + return helixTheme(name, raw) +} + +func readHelixRaw(path string, dirs []string, depth int) *helixRaw { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + var doc map[string]any + if err := toml.Unmarshal(b, &doc); err != nil { + return nil + } + raw := &helixRaw{scopes: map[string]helixStyle{}, palette: map[string]string{}} + if parent, _ := doc["inherits"].(string); parent != "" && depth < 8 { + if p := findHelixTheme(parent, dirs); p != "" { + if base := readHelixRaw(p, dirs, depth+1); base != nil { + raw = base + } + } + } + if pal, ok := doc["palette"].(map[string]any); ok { + for k, v := range pal { + if s, ok := v.(string); ok { + raw.palette[k] = s + } + } + } + delete(doc, "palette") + delete(doc, "inherits") + flattenHelix("", doc, raw.scopes) + return raw +} + +func findHelixTheme(name string, dirs []string) string { + if name == "" || strings.ContainsAny(name, "/\\.") { + return "" + } + for _, d := range dirs { + p := filepath.Join(d, name+".toml") + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// flattenHelix walks the decoded document into scope -> style. A table is a +// style when it holds only style keys; anything else is a dotted scope name +// someone wrote out as a table, so it keeps descending. +func flattenHelix(prefix string, m map[string]any, into map[string]helixStyle) { + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + switch v := v.(type) { + case string: + into[key] = helixStyle{fg: v} + case map[string]any: + if st, ok := helixStyleOf(v); ok { + into[key] = st + } else { + flattenHelix(key, v, into) + } + } + } +} + +func helixStyleOf(m map[string]any) (helixStyle, bool) { + var st helixStyle + for k, v := range m { + switch k { + case "fg": + st.fg, _ = v.(string) + case "bg": + st.bg, _ = v.(string) + case "modifiers": + mods, _ := v.([]any) + for _, mod := range mods { + switch mod { + case "italic": + st.italic = true + case "bold": + st.bold = true + } + } + case "underline", "underline_style", "underline_color": + // decoration we have no use for, but still a style + default: + return helixStyle{}, false + } + } + return st, true +} + +// the sixteen names a helix theme may use without a palette entry +var termHex = map[string]string{ + "black": "#000000", "red": "#cd3131", "green": "#0dbc79", "yellow": "#e5e510", + "blue": "#2472c8", "magenta": "#bc3fbc", "cyan": "#11a8cd", "gray": "#8a8a8a", + "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543", + "light-blue": "#3b8eea", "light-magenta": "#d670d6", "light-cyan": "#29b8db", + "light-gray": "#c0c0c0", "white": "#e5e5e5", +} + +func (r *helixRaw) color(name string) string { + if name == "" || name == "default" { + return "" + } + if c, ok := r.palette[name]; ok { + name = c + } + if strings.HasPrefix(name, "#") { + return hex6(okColor(name)) + } + return termHex[name] +} + +// fg / bg take the first of several scopes that names one, so a theme that +// skips a scope is answered by whatever it does say. Scope names fall back +// along their dots the way helix resolves them. +func (r *helixRaw) fg(scopes ...string) string { return r.pick(true, scopes) } +func (r *helixRaw) bg(scopes ...string) string { return r.pick(false, scopes) } + +func (r *helixRaw) pick(wantFg bool, scopes []string) string { + for _, scope := range scopes { + for key := scope; key != ""; { + if st, ok := r.scopes[key]; ok { + c := st.bg + if wantFg { + c = st.fg + } + if c := r.color(c); c != "" { + return c + } + } + i := strings.LastIndex(key, ".") + if i < 0 { + break + } + key = key[:i] + } + } + return "" +} + +// zed syntax keys helix spells differently; everything else either matches or +// falls back along its dots (punctuation.bracket -> punctuation). +var helixAlias = map[string]string{ + "number": "constant.numeric", + "boolean": "constant.builtin.boolean", + "property": "variable.other.member", + "preproc": "keyword.directive", + "primary": "variable", + "enum": "type.enum", + "string.escape": "constant.character.escape", + "string.regex": "string.regexp", + "variable.special": "variable.builtin", + "comment.doc": "comment.block.documentation", + "link_uri": "markup.link.url", + "link_text": "markup.link.text", + "title": "markup.heading", + "emphasis": "markup.italic", + "emphasis.strong": "markup.bold", +} + +func helixTheme(name string, raw *helixRaw) *Theme { + bg := raw.bg("ui.background", "ui.window") + fg := raw.fg("ui.text", "ui.window") + if bg == "" || fg == "" { + return nil // a theme that paints on the terminal's colors, not ours + } + t := &Theme{ + ID: "helix/" + slug(name), Source: "helix", Name: name, + Appearance: "dark", Bg: bg, Fg: fg, + Syntax: map[string]SyntaxStyle{}, + } + if lum(bg) > 0.5 { + t.Appearance = "light" + } + // A helix theme dresses an editor, not a web page, so it is read for what + // the page needs and fill() closes the rest. The quiet backgrounds come + // first: a selected menu row is often the theme's loudest color. + t.Panel = raw.bg("ui.statusline", "ui.menu", "ui.popup") + t.Hover = raw.bg("ui.cursorline.primary", "ui.selection", "ui.menu.selected") + t.Border = raw.fg("ui.window") + t.LineNo = raw.fg("ui.linenr") + t.Muted = raw.fg("comment", "ui.linenr") + t.Accent = raw.fg("function", "keyword", "type") + t.Note = raw.fg("warning", "diagnostic.warning", "constant.numeric") + + for scope, st := range raw.scopes { + if strings.HasPrefix(scope, "ui.") { + continue + } + if c := raw.color(st.fg); c != "" { + t.Syntax[scope] = syntaxStyle(c, st.italic, st.bold) + } + } + for key, scope := range helixAlias { + if _, ok := t.Syntax[key]; ok { + continue + } + if st, ok := t.Syntax[resolveKey(t, scope)]; ok { + t.Syntax[key] = st + } + } + t.fill() + t.setPlayers(t.syntaxColors()) + return t +} + +func syntaxStyle(color string, italic, bold bool) SyntaxStyle { + s := SyntaxStyle{Color: color} + if italic { + it := "italic" + s.FontStyle = &it + } + if bold { + w := 700.0 + s.FontWeight = &w + } + return s +} + +func first(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// slug is a theme's id in an attribute and a URL: ascii, lowercase, dashed. +func slug(s string) string { + var b strings.Builder + dash := false + for _, r := range strings.ToLower(s) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + dash = false + } else if !dash && b.Len() > 0 { + b.WriteByte('-') + dash = true + } + } + return strings.Trim(b.String(), "-") +} diff --git a/vrsite/themes_test.go b/vrsite/themes_test.go new file mode 100644 index 0000000..4ef1cca --- /dev/null +++ b/vrsite/themes_test.go @@ -0,0 +1,294 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// everything a helix theme reader has to survive: quoted scope keys, a +// palette, one of the terminal color names, modifiers, and an inline table +// broken over lines the way a formatter leaves it +const helixSrc = ` +# a comment +"ui.background" = { bg = "base" } +"ui.text" = { fg = "text" } +"ui.linenr" = { fg = "muted" } +"ui.statusline" = { fg = "text", bg = "surface" } +"ui.cursorline.primary" = { bg = "highlight" } +"ui.menu.selected" = { fg = "base", bg = "rose" } +"ui.window" = { fg = "muted" } +comment = { fg = "muted", modifiers = ["italic"] } +keyword = { fg = "pine", modifiers = [ + "bold", +] } +string = "gold" +function = { fg = "rose" } +"constant.numeric" = { fg = "iris" } +"variable.other.member" = { fg = "foam" } +"punctuation" = { fg = "muted" } +warning = { fg = "gold" } +type = "white" + +[palette] +base = "#191724" +text = "#e0def4" +muted = "#6e6a86" +surface = "#1f1d2e" +highlight = "#21202e" +pine = "#31748f" +gold = "#f6c177" +rose = "#ebbcba" +iris = "#c4a7e7" +foam = "#9ccfd8" +` + +func writeTheme(t *testing.T, dir, name, body string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestHelixTheme(t *testing.T) { + dir := t.TempDir() + path := writeTheme(t, dir, "rose_pine.toml", helixSrc) + th := loadHelixTheme(path, []string{dir}) + if th == nil { + t.Fatal("theme did not load") + } + for _, tc := range []struct{ what, got, want string }{ + {"id", th.ID, "helix/rose-pine"}, + {"source", th.Source, "helix"}, + {"name", th.Name, "rose_pine"}, + {"appearance", th.Appearance, "dark"}, + {"bg", th.Bg, "#191724"}, + {"fg", th.Fg, "#e0def4"}, + {"panel", th.Panel, "#1f1d2e"}, + {"line numbers", th.LineNo, "#6e6a86"}, + {"border", th.Border, "#6e6a86"}, + {"accent", th.Accent, "#ebbcba"}, // function + {"note", th.Note, "#f6c177"}, // warning + {"hover", th.Hover, "#21202e"}, // cursorline, not the loud menu row + {"muted", th.Muted, "#6e6a86"}, // comment + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.what, tc.got, tc.want) + } + } + // syntax keys the site paints with, spelled the way helix spells them + for _, tc := range []struct{ key, want string }{ + {"comment", "#6e6a86"}, + {"keyword", "#31748f"}, + {"string", "#f6c177"}, + {"type", "#e5e5e5"}, // a terminal color name, not a palette one + {"number", "#c4a7e7"}, // zed's name for constant.numeric + {"property", "#9ccfd8"}, // zed's name for variable.other.member + {"punctuation.bracket", "#6e6a86"}, // falls back along its dots + } { + if got := th.syn(tc.key); got != tc.want { + t.Errorf("syntax %s = %q, want %q", tc.key, got, tc.want) + } + } + if s := th.Syntax["comment"]; s.FontStyle == nil || *s.FontStyle != "italic" { + t.Errorf("comment is not italic: %+v", s) + } + if s := th.Syntax["keyword"]; s.FontWeight == nil || *s.FontWeight != 700 { + t.Errorf("keyword is not bold: %+v", s) + } + // agent colors: from the code's own colors, and none of them invisible + if len(th.Players) < 3 { + t.Fatalf("agent colors = %v, want at least 3", th.Players) + } + for _, p := range th.Players { + if colorDist(p, hex6(th.Bg)) <= 60 || colorDist(p, hex6(th.Fg)) <= 60 { + t.Errorf("agent color %s vanishes against the theme", p) + } + } +} + +// A child theme restating only the palette is how helix ships most of its +// families: the parent's scopes have to resolve against the child's colors. +func TestHelixInherits(t *testing.T) { + dir := t.TempDir() + writeTheme(t, dir, "parent.toml", helixSrc) + path := writeTheme(t, dir, "child.toml", ` +inherits = "parent" +comment = { fg = "muted" } + +[palette] +base = "#eff1f5" +text = "#4c4f69" +muted = "#7c7f93" +surface = "#e6e9ef" +highlight = "#dce0e8" +pine = "#1e66f5" +gold = "#df8e1d" +rose = "#ea76cb" +iris = "#8839ef" +foam = "#04a5e5" +`) + th := loadHelixTheme(path, []string{dir}) + if th == nil { + t.Fatal("child theme did not load") + } + if th.Bg != "#eff1f5" || th.Fg != "#4c4f69" { + t.Errorf("inherited scopes kept the parent's palette: bg=%s fg=%s", th.Bg, th.Fg) + } + if th.Appearance != "light" { + t.Errorf("appearance = %q, want light for a pale background", th.Appearance) + } + if got := th.syn("keyword"); got != "#1e66f5" { + t.Errorf("inherited keyword = %q, want the child's pine", got) + } + // the child's own entry wins over the parent's, modifiers and all + if s := th.Syntax["comment"]; s.FontStyle != nil { + t.Errorf("child comment kept the parent's italics: %+v", s) + } +} + +// A theme that names almost nothing still has to dress a whole page. +func TestFillClosesTheGaps(t *testing.T) { + th := &Theme{Bg: "#101010", Fg: "#f0f0f0", Syntax: map[string]SyntaxStyle{ + "function": {Color: "#88c0d0"}, + }} + th.fill() + for _, tc := range []struct{ what, got string }{ + {"panel", th.Panel}, {"border", th.Border}, {"line numbers", th.LineNo}, + {"muted", th.Muted}, {"accent", th.Accent}, {"note", th.Note}, {"hover", th.Hover}, + } { + if okColor(tc.got) == "" { + t.Errorf("%s = %q, want a color", tc.what, tc.got) + } + } + if th.Accent != "#88c0d0" { + t.Errorf("accent = %q, want the theme's own function color", th.Accent) + } + if th.Panel == th.Bg || th.Hover == th.Bg { + t.Errorf("panel %q and hover %q must lift off the background %q", th.Panel, th.Hover, th.Bg) + } + // a panel the text would be lost in is not kept + lost := &Theme{Bg: "#f7f3ee", Fg: "#605a52", Panel: "#605a52"} + lost.fill() + if lost.Panel == "#605a52" { + t.Error("kept a panel the foreground disappears into") + } +} + +func TestZedFamily(t *testing.T) { + fam, err := parseZedFamily([]byte(`{"name":"Fake","themes":[ + {"name":"Fake Dark","appearance":"dark","style":{ + "editor.background":"#101010","editor.foreground":"#e0e0e0", + "text.accent":"0f4b6e","warning":"#e0af68", + "players":[{"cursor":"#7aa2f7"},{"cursor":"#9ece6a"}], + "syntax":{"keyword":{"color":"#bb9af7","font_style":"italic"}, + "string":{"color":"9ece6a"}}}}, + {"name":"Fake Light","appearance":"light","style":{ + "editor.background":"#fafafa","editor.foreground":"#383a42"}}]}`)) + if err != nil { + t.Fatal(err) + } + if len(fam) != 2 { + t.Fatalf("themes = %d, want 2", len(fam)) + } + dark, light := fam[0], fam[1] + if dark.ID != "zed/fake-dark" || light.ID != "zed/fake-light" { + t.Errorf("ids = %q, %q", dark.ID, light.ID) + } + // a theme in the wild does drop the odd "#": what is not a color is + // treated as unsaid, and filled in rather than emitted as nonsense + if dark.Accent == "0f4b6e" { + t.Error("kept a color with no #") + } + if _, ok := dark.Syntax["string"]; ok { + t.Error("kept a syntax color with no #") + } + if dark.syn("keyword") != "#bb9af7" { + t.Errorf("keyword = %q", dark.syn("keyword")) + } + if len(dark.Players) < 2 || dark.Players[0] != "#7aa2f7" { + t.Errorf("agent colors = %v, want the player palette first", dark.Players) + } + // nothing to work from but bg and fg, and still a whole theme + if okColor(light.Muted) == "" || okColor(light.Hover) == "" { + t.Errorf("light theme left gaps: %+v", light) + } +} + +// The picker's two files: a block per theme, and a menu that names them. +func TestThemeFiles(t *testing.T) { + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + helix := loadHelixTheme(writeTheme(t, t.TempDir(), "rose_pine.toml", helixSrc), nil) + ts := themesFrom([]*Theme{dark, light, helix}, dark, light) + + css := string(ts.CSS) + for _, th := range ts.List { + if !strings.Contains(css, `:root[data-theme="`+th.ID+`"]`) { + t.Errorf("no css block for %s", th.ID) + } + if !strings.Contains(string(ts.JS), th.ID) { + t.Errorf("%s is not in the menu", th.ID) + } + } + for _, want := range []string{"--bg:", "--fg:", "--ag0:", "color-scheme: dark", "color-scheme: light"} { + if !strings.Contains(css, want) { + t.Errorf("themes.css never says %q", want) + } + } + // every declaration has to be worth something to the browser + for _, line := range strings.Split(css, "\n") { + if l := strings.TrimSpace(line); strings.HasPrefix(l, "--") && strings.HasSuffix(l, ": ;") { + t.Errorf("empty declaration: %s", l) + } + } + if n := strings.Count(string(ts.JS), `"id":`); n != len(ts.List) { + t.Errorf("menu has %d themes, want %d", n, len(ts.List)) + } +} + +// The two editors' theme directories, as they are on a machine that has them. +func TestDiscoverFindsBothEditors(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data")) + writeTheme(t, filepath.Join(home, "config", "zed", "themes"), "mine.json", + `{"name":"Mine","themes":[{"name":"Mine Dark","appearance":"dark","style":{ + "editor.background":"#101010","editor.foreground":"#e0e0e0"}}]}`) + writeTheme(t, filepath.Join(home, "data", "zed", "extensions", "installed", "ext", "themes"), + "ext.json", `{"name":"Ext","themes":[{"name":"Ext Light","appearance":"light","style":{ + "editor.background":"#fafafa","editor.foreground":"#383a42"}}]}`) + writeTheme(t, filepath.Join(home, "config", "helix", "themes"), "mine_helix.toml", helixSrc) + + found := map[string]*Theme{} + for _, th := range discoverThemes() { + found[th.ID] = th + } + for _, want := range []string{"zed/mine-dark", "zed/ext-light", "helix/mine-helix"} { + if found[want] == nil { + t.Errorf("did not find %s", want) + } + } +} + +// Coverage colors are mixed in the browser so every theme can supply them. +func TestMaskMix(t *testing.T) { + s := &Site{Agents: []*Agent{{Bit: 1}, {Bit: 2}, {Bit: 4}}} + for _, tc := range []struct { + mask uint8 + want string + }{ + {1, "var(--ag0)"}, + {5, "color-mix(in srgb, var(--ag0) 50.000%, var(--ag2))"}, + {7, "color-mix(in srgb, var(--ag0) 33.333%, color-mix(in srgb, var(--ag1) 50.000%, var(--ag2)))"}, + } { + if got := s.maskMix(tc.mask); got != tc.want { + t.Errorf("mask %d = %q, want %q", tc.mask, got, tc.want) + } + } +} |
