diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 17:05:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-07-30 17:56:23 -0300 |
| commit | 8ec4e135e318c7799b2576a8729493c063da4a97 (patch) | |
| tree | ab37cdbb7f478eb996264cf941891f127f303c85 /vrsite | |
| parent | cb05c363045bf0e7af5858f6d7bc26f026fa9d70 (diff) | |
| download | notevi-8ec4e135e318c7799b2576a8729493c063da4a97.tar.gz notevi-8ec4e135e318c7799b2576a8729493c063da4a97.zip | |
Diffstat (limited to 'vrsite')
34 files changed, 2719 insertions, 166 deletions
diff --git a/vrsite/app_test.go b/vrsite/app_test.go index c56fb0d..4db74d4 100644 --- a/vrsite/app_test.go +++ b/vrsite/app_test.go @@ -1,12 +1,15 @@ package main import ( + "crypto/sha256" + "fmt" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" + "regexp" "strings" "testing" "time" @@ -23,7 +26,7 @@ const ( func testSite(t *testing.T) *Site { t.Helper() - s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB)))) + s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB))), "") s.Theme, s.Light = loadTheme("", "", "dark"), loadTheme("", "", "light") return s } @@ -55,7 +58,7 @@ func TestAgentColorsAreBounded(t *testing.T) { for i := 0; i < maxAgents+3; i++ { lines = append(lines, `{"time":"2026-07-20T10:00:00Z","op":"note","agent":"a`+string(rune('a'+i))+`","change":"aaa","note_id":1,"text":"x"}`) } - s := buildModel("t", parseLog([]byte(logLines(lines...)))) + s := buildModel("t", parseLog([]byte(logLines(lines...))), "") if len(s.Agents) != maxAgents+3 { t.Fatalf("agents = %d", len(s.Agents)) } @@ -72,6 +75,60 @@ func TestAgentColorsAreBounded(t *testing.T) { } } +// A view of one change holds only what was recorded there — but note ids stay +// positional over the whole log, so links survive the scope changing. +func TestModelScopedToAChange(t *testing.T) { + otherChange := `{"time":"2026-07-26T10:00:00Z","op":"note","session":"s3","agent":"codex","dir":"/repo","change":"bbb","file":"src/lib.rs","start":5,"end":6,"note_id":1,"text":"at another change"}` + otherRead := `{"time":"2026-07-26T09:00:00Z","op":"read","session":"s3","agent":"codex","dir":"/repo","change":"bbb","file":"src/other.rs","start":1,"end":9}` + entries := parseLog([]byte(logLines(grepA, readA, noteA, noteB, otherRead, otherChange))) + + all := buildModel("t", entries, "") + if len(all.Notes) != 3 || all.NotesTotal != 3 { + t.Fatalf("unscoped notes = %d of %d", len(all.Notes), all.NotesTotal) + } + if all.Change != "aaa" || all.Votes["aaa"] != 4 || all.Votes["bbb"] != 2 { + t.Errorf("change %q votes %v", all.Change, all.Votes) + } + if strings.Join(all.Ranked, ",") != "aaa,bbb" { + t.Errorf("ranked = %v, want the most-logged change first", all.Ranked) + } + + one := buildModel("t", entries, "aaa") + if len(one.Notes) != 2 { + t.Fatalf("scoped notes = %d, want the two at aaa", len(one.Notes)) + } + if one.NotesTotal != 3 || one.NotesAt["aaa"] != 2 || one.NotesAt["bbb"] != 1 { + t.Errorf("a scoped view must still know the whole log: %d %v", one.NotesTotal, one.NotesAt) + } + if _, ok := one.Files["src/other.rs"]; ok { + t.Error("a file only read at another change leaked into the view") + } + // the read at bbb covered lines 1-9 of another file; lib.rs keeps only aaa's + if f := one.Files["src/lib.rs"]; f == nil || f.Reads != 1 || len(f.Notes) != 1 { + t.Errorf("src/lib.rs at aaa = %+v", f) + } + // ids are the note's position in the log, the same in both views + ids := map[string]int{} + for _, n := range all.Notes { + ids[n.Text] = n.ID + } + for _, n := range one.Notes { + if ids[n.Text] != n.ID { + t.Errorf("note %q is id %d scoped, %d unscoped", n.Text, n.ID, ids[n.Text]) + } + } + // agent colors come from the whole log, so they do not shuffle when the + // scope narrows + if len(one.Agents) != len(all.Agents) { + t.Errorf("agents = %d scoped, %d unscoped", len(one.Agents), len(all.Agents)) + } + for i := range all.Agents { + if one.Agents[i].Name != all.Agents[i].Name || one.Agents[i].Bit != all.Agents[i].Bit { + t.Errorf("agent %d differs: %+v vs %+v", i, one.Agents[i], all.Agents[i]) + } + } +} + func TestFilterNotes(t *testing.T) { s := testSite(t) for _, tc := range []struct { @@ -186,13 +243,43 @@ func TestHrefModes(t *testing.T) { if got := live.Href("code", "src/a.rs"); got != "/code/src/a.rs?rev=xyz" { t.Errorf("live code href with rev = %q", got) } - if got := live.Href("notes"); got != "/notes" { + // notes are read against a change too, so the link keeps it + if got := live.Href("notes"); got != "/notes?rev=xyz" { t.Errorf("live notes href = %q", got) } + // "@" is a revset like any other; dropping it would slide the reader back + // to the change the log points at + if got := (&page{Live: true, Root: "/", Rev: "@"}).Href("files"); got != "/files?rev=%40" { + t.Errorf("href browsing @ = %q", got) + } + wide := &page{Live: true, Root: "/", Kind: "file", Rev: "xyz", Scope: "all", Path: "/code/a.rs"} + if got := wide.Href("files"); got != "/files?rev=xyz&scope=all" { + t.Errorf("wide files href = %q", got) + } + if got := wide.ScopeHref("change"); got != "/code/a.rs?rev=xyz" { + t.Errorf("narrowing href = %q", got) + } + if got := wide.BackHref(); got != "/code/a.rs?scope=all" { + t.Errorf("back href = %q", got) + } + if got := (&page{Live: true, Kind: "change", Path: "/change/abc"}).JumpPath(); got != "/files" { + t.Errorf("jump path from a change page = %q", got) + } static := &page{Root: "../"} if got := static.Href("code", "src/a.rs"); got != "../code/src/a.rs.html" { t.Errorf("static code href = %q", got) } + // a note link carries the note itself, so the page can open focused on it + n := &Note{ID: 7, File: "src/a.rs", Start: 12, End: 20} + if got := (&page{Live: true, Root: "/"}).NoteHref(n); got != "/code/src/a.rs?note=7#L12" { + t.Errorf("live note href = %q", got) + } + if got := (&page{Live: true, Root: "/", Rev: "xyz"}).NoteHref(n); got != "/code/src/a.rs?rev=xyz¬e=7#L12" { + t.Errorf("note href at a revision = %q", got) + } + if got := static.NoteHref(n); got != "../code/src/a.rs.html?note=7#L12" { + t.Errorf("static note href = %q", got) + } if got := static.Href("change", "abc"); got != "" { t.Errorf("static change href = %q, want none", got) } @@ -280,6 +367,229 @@ func TestServerPages(t *testing.T) { } } +// The current change is on every page, and the view follows it. +func TestCurrentChangeRunsTheView(t *testing.T) { + sv, logPath, change := testServer(t) + + // a second change with nothing recorded at it + empty := filepath.Join(sv.repo, "later.txt") + if err := os.WriteFile(empty, []byte("later\n"), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("jj", "new", "-m", "a later change") + cmd.Dir, cmd.Env = sv.repo, append(os.Environ(), "JJ_USER=test", "[email protected]", "HOME="+sv.repo) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("jj new: %v\n%s", err, out) + } + later, err := resolveChange(sv.repo, "@") + if err != nil { + t.Fatal(err) + } + h := sv.handler() + + // the default page is the change the log points at, scoped to it + body := get(t, h, "/", false).Body.String() + for _, want := range []string{short(change), "notes here", `class="changebar`} { + if !strings.Contains(body, want) { + t.Errorf("the index does not show the current change (%q missing)", want) + } + } + if !strings.Contains(body, "second line matters") { + t.Error("the note recorded at this change is missing") + } + + // browsing a change nothing was recorded at must not look empty: it says so + // and falls back to the whole log + body = get(t, h, "/?rev="+later, false).Body.String() + if !strings.Contains(body, "nothing recorded at this change") { + t.Errorf("no explanation for an empty change:\n%s", body[:min(len(body), 900)]) + } + if !strings.Contains(body, "second line matters") { + t.Error("the fallback should show the whole log, not nothing") + } + + // a note written from a page at that change is recorded there + form := url.Values{"text": {"written at the later change"}, "rev": {later}} + r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.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", w.Code) + } + entries := parseLog(mustRead(t, logPath)) + if last := entries[len(entries)-1]; last.Change != later { + t.Errorf("note recorded at %q, want the change being browsed %q", last.Change, later) + } + + // a note written with no change named lands on the one the log points at, + // not on the working copy, which has moved on + form = url.Values{"text": {"written with nothing named"}} + r = httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + entries = parseLog(mustRead(t, logPath)) + if last := entries[len(entries)-1]; last.Change != change { + t.Errorf("unaimed note recorded at %q, want the log's change %q", last.Change, change) + } + + // now that change has a note, scoping to it shows that one alone + body = get(t, h, "/notes?rev="+later, false).Body.String() + if strings.Contains(body, "second line matters") { + t.Error("a note from another change leaked into the scoped view") + } + if !strings.Contains(body, "written at the later change") { + t.Error("the note recorded here is missing") + } + // and the wide view has both, with the other change's note marked + body = get(t, h, "/notes?rev="+later+"&scope=all", false).Body.String() + if !strings.Contains(body, "second line matters") || !strings.Contains(body, "written at the later change") { + t.Error("scope=all must show every change's notes") + } + if !strings.Contains(body, "note am1 away") && !strings.Contains(body, "away") { + t.Error("a note from another change is not marked as such") + } + // links keep both the change and the scope + if !strings.Contains(body, "rev="+later+"&scope=all") { + t.Error("links drop the change or the scope") + } +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} + +// ── the server keeps nothing of its own ─────────────────────────────────── +// +// Every byte it writes goes to the log; everything it holds in memory is a +// cache of the log and the repo, so a restart changes nothing and a repo that +// moves underneath it is followed, not remembered. + +func repoState(t *testing.T, repo string) string { + t.Helper() + sum := sha256.New() + err := filepath.WalkDir(repo, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + name := d.Name() + if d.IsDir() { + if name == ".jj" || name == ".git" { + return filepath.SkipDir // caches and mtimes of their own + } + return nil + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + fmt.Fprintf(sum, "%s %x\n", path, sha256.Sum256(b)) + return nil + }) + if err != nil { + t.Fatal(err) + } + // the operation log head: proof we never asked jj to do anything + op, err := jjRun(repo, "op", "log", "-n", "1", "--no-graph", "-T", "id") + if err != nil { + t.Fatal(err) + } + return fmt.Sprintf("%x %s", sum.Sum(nil), op) +} + +func TestServerWritesOnlyTheLog(t *testing.T) { + sv, logPath, change := testServer(t) + h := sv.handler() + before := repoState(t, sv.repo) + logBefore := len(mustRead(t, logPath)) + + for _, path := range []string{"/", "/files", "/notes", "/code/hello.txt", "/tree", + "/jj?revset=all()", "/jj?kind=op", "/change/" + change, "/activity", "/style.css"} { + if w := get(t, h, path, false); w.Code != 200 { + t.Errorf("GET %s = %d", path, w.Code) + } + } + form := url.Values{"text": {"a note from the browser"}, "file": {"hello.txt"}, "change": {change}} + r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.ServeHTTP(httptest.NewRecorder(), r) + + if after := repoState(t, sv.repo); after != before { + t.Errorf("the repository changed:\n%s\n%s", before, after) + } + if len(mustRead(t, logPath)) <= logBefore { + t.Error("the note did not reach the log") + } + // nothing else appeared next to the log either + dir := filepath.Dir(logPath) + names, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(names) != 1 || filepath.Join(dir, names[0].Name()) != logPath { + var got []string + for _, n := range names { + got = append(got, n.Name()) + } + t.Errorf("the server left files of its own: %v", got) + } +} + +var agoRe = regexp.MustCompile(`\d+[smhd] ago|just now`) + +func TestServerHoldsNoStateOfItsOwn(t *testing.T) { + sv, logPath, change := testServer(t) + h := sv.handler() + form := url.Values{"text": {"written before the restart"}, "file": {"hello.txt"}, + "start": {"2"}, "change": {change}} + r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + 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() + 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") + is := agoRe.ReplaceAllString(get(t, fresh, path, false).Body.String(), "AGO") + if was != is { + t.Errorf("GET %s differs after a restart — the server is holding state the log does not", path) + } + } +} + +// A change id is a pointer: editing files rewrites the commit under it. Pages +// must follow the content, not a cached snapshot of it. +func TestPagesFollowTheRepo(t *testing.T) { + sv, _, _ := testServer(t) + h := sv.handler() + if body := get(t, h, "/code/hello.txt", false).Body.String(); !strings.Contains(body, "three") { + t.Fatalf("the file did not render:\n%s", body[:min(len(body), 400)]) + } + if err := os.WriteFile(filepath.Join(sv.repo, "hello.txt"), []byte("one\ntwo\nrewritten\n"), 0o644); err != nil { + t.Fatal(err) + } + // someone else's jj command snapshots it: same change, new commit + cmd := exec.Command("jj", "status") + cmd.Dir, cmd.Env = sv.repo, append(os.Environ(), "JJ_USER=test", "[email protected]", "HOME="+sv.repo) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("jj status: %v\n%s", err, out) + } + body := get(t, h, "/code/hello.txt", false).Body.String() + if !strings.Contains(body, "rewritten") { + t.Error("the page still shows the content the change had when it was first read") + } + if strings.Contains(body, "three") { + t.Error("the old content is still on the page") + } +} + func TestNotesFilterOverHTTP(t *testing.T) { sv, _, _ := testServer(t) h := sv.handler() diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js index 79dc190..104cc85 100644 --- a/vrsite/assets/app.js +++ b/vrsite/assets/app.js @@ -138,6 +138,55 @@ for (const ta of document.querySelectorAll(".notform textarea")) if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) ta.form.requestSubmit(); }); +// ── conversation ────────────────────────────────────────────────────────── +// A session page opened from a note lands on that note, flashed the same way +// clicking a noted line flashes it. +const conv = document.querySelector(".conv[data-anchor]"); +if (conv && conv.dataset.anchor) { + const here = document.getElementById(conv.dataset.anchor); + if (here) { + here.scrollIntoView({block: "start"}); + here.classList.remove("flash"); void here.offsetWidth; here.classList.add("flash"); + } +} + +// The trip back: a note in a conversation opens the code it was written +// about. The card is the target, so aim anywhere on it — except at the links +// it already carries. +const goTo = w => { location.href = w.dataset.href; }; +for (const w of document.querySelectorAll(".notewrap[data-href]")) { + w.addEventListener("click", e => { if (!e.target.closest("a")) goTo(w); }); + w.addEventListener("keydown", e => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goTo(w); } + }); +} + +// ── arriving at a note ──────────────────────────────────────────────────── +// ?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. +const want = new URLSearchParams(location.search).get("note"); +if (want && document.body.dataset.kind === "file" && /^\d+$/.test(want)) { + setMode("notes"); + const card = document.getElementById("note-" + want); + if (card) { + // the card's own range, not the lines' data-note: a line belongs to the + // first note that claimed it, so overlapping notes would mark nothing + const from = +card.dataset.start, to = +card.dataset.end || +card.dataset.start; + let first = null; + for (let n = from; n > 0 && n <= to; n++) { + const li = document.getElementById("L" + n); + if (!li) continue; + li.classList.add("focus"); + first = first || li; + } + // after the browser has had its own go at the #L anchor + if (first) requestAnimationFrame(() => first.scrollIntoView({block: "center"})); + card.scrollIntoView({block: "nearest"}); + card.classList.remove("flash"); void card.offsetWidth; card.classList.add("flash"); + } +} + // ── note filters ────────────────────────────────────────────────────────── // Live pages let the server filter (htmx); the static export filters the // rendered list in place, so the same form works either way. diff --git a/vrsite/assets/bash.scm b/vrsite/assets/bash.scm new file mode 100644 index 0000000..4c7926a --- /dev/null +++ b/vrsite/assets/bash.scm @@ -0,0 +1,21 @@ +; Bash highlights, from tree-sitter/tree-sitter-bash v0.25.1 +; queries/highlights.scm (MIT). Reordered so expansions stay visible inside the +; strings that contain them, and capture names mapped onto zed syntax keys. +; Upstream's @embedded captures are dropped: zed paints `embedded` as plain +; foreground, so they only cost query time. + +["$" "&&" "||" ">" ">>" "<" "|" ";" "=" "!"] @operator + +["case" "do" "done" "elif" "else" "esac" "export" "fi" "for" "function" "if" + "in" "local" "readonly" "select" "then" "unset" "until" "while"] @keyword + +[(string) (raw_string) (heredoc_body) (heredoc_start)] @string +(variable_name) @property +(file_descriptor) @number + +(command_name) @function +(function_definition name: (word) @function) +((command (_) @constant) + (#match? @constant "^-")) + +(comment) @comment diff --git a/vrsite/assets/c.scm b/vrsite/assets/c.scm new file mode 100644 index 0000000..789bf93 --- /dev/null +++ b/vrsite/assets/c.scm @@ -0,0 +1,36 @@ +; C highlights, from tree-sitter/tree-sitter-c v0.24.2 queries/highlights.scm +; (MIT). Reordered for later-patterns-win, capture names mapped onto zed syntax +; keys (preprocessor directives get @preproc rather than upstream's @keyword, +; which zed themes colour separately). Also loaded ahead of cpp.scm, the way +; upstream composes the C++ highlights. + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +[";" "," "."] @punctuation.delimiter +["--" "-" "-=" "->" "=" "!=" "*" "&" "&&" "+" "++" "+=" "<" "==" ">" + "||"] @operator + +["break" "case" "const" "continue" "default" "do" "else" "enum" "extern" + "for" "if" "inline" "return" "sizeof" "static" "struct" "switch" "typedef" + "union" "volatile" "while"] @keyword + +["#define" "#elif" "#else" "#endif" "#if" "#ifdef" "#ifndef" + "#include"] @preproc +(preproc_directive) @preproc + +[(type_identifier) (primitive_type) (sized_type_specifier)] @type +(field_identifier) @property +(statement_identifier) @label +((identifier) @constant + (#match? @constant "^[A-Z][A-Z0-9_]*$")) + +(call_expression function: (identifier) @function) +(call_expression function: (field_expression + field: (field_identifier) @function.method)) +(function_declarator declarator: (identifier) @function) +(preproc_function_def name: (identifier) @function) + +(number_literal) @number +(null) @constant +[(string_literal) (char_literal) (system_lib_string)] @string +(escape_sequence) @string.escape +(comment) @comment diff --git a/vrsite/assets/cpp.scm b/vrsite/assets/cpp.scm new file mode 100644 index 0000000..e939a7a --- /dev/null +++ b/vrsite/assets/cpp.scm @@ -0,0 +1,25 @@ +; C++ highlights, from tree-sitter/tree-sitter-cpp v0.23.4 +; queries/highlights.scm (MIT). A supplement loaded after c.scm, as upstream +; composes it, so these patterns win. Capture names mapped onto zed syntax +; keys; `this` gets @variable.special to match what rust.scm does for `self`. + +["catch" "class" "co_await" "co_return" "co_yield" "constexpr" "constinit" + "consteval" "delete" "explicit" "final" "friend" "mutable" "namespace" + "noexcept" "new" "override" "private" "protected" "public" "template" + "throw" "try" "typename" "using" "concept" "requires" "virtual"] @keyword + +(auto) @type +((namespace_identifier) @type + (#match? @type "^[A-Z]")) + +(call_expression function: (qualified_identifier + name: (identifier) @function)) +(template_function name: (identifier) @function) +(template_method name: (field_identifier) @function.method) +(function_declarator declarator: (qualified_identifier + name: (identifier) @function)) +(function_declarator declarator: (field_identifier) @function.method) + +(raw_string_literal) @string +(this) @variable.special +(null "nullptr" @constant) diff --git a/vrsite/assets/go.scm b/vrsite/assets/go.scm new file mode 100644 index 0000000..56f676c --- /dev/null +++ b/vrsite/assets/go.scm @@ -0,0 +1,34 @@ +; Go highlights, from tree-sitter/tree-sitter-go v0.25.0 queries/highlights.scm +; (MIT). Reordered — upstream is written first-pattern-wins, this engine paints +; later patterns over earlier ones — capture names mapped onto zed syntax keys, +; and the builtin-function #match? turned into #any-of?, which needs no regex. + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +[";" "," "." ":"] @punctuation.delimiter +["--" "-" "-=" ":=" "!" "!=" "..." "*" "*=" "/" "/=" "&" "&&" "&=" "%" "%=" + "^" "^=" "+" "++" "+=" "<-" "<" "<<" "<<=" "<=" "=" "==" ">" ">=" ">>" ">>=" + "|" "|=" "||" "~"] @operator + +["break" "case" "chan" "const" "continue" "default" "defer" "else" + "fallthrough" "for" "func" "go" "goto" "if" "import" "interface" "map" + "package" "range" "return" "select" "struct" "switch" "type" "var"] @keyword + +(type_identifier) @type +(field_identifier) @property +(label_name) @label + +(function_declaration name: (identifier) @function) +(method_declaration name: (field_identifier) @function.method) +(call_expression function: (identifier) @function) +(call_expression function: (selector_expression + field: (field_identifier) @function.method)) +((call_expression function: (identifier) @function.builtin) + (#any-of? @function.builtin "append" "cap" "clear" "close" "complex" "copy" + "delete" "imag" "len" "make" "max" "min" "new" "panic" "print" "println" + "real" "recover")) + +[(int_literal) (float_literal) (imaginary_literal)] @number +[(true) (false) (nil) (iota)] @constant +[(interpreted_string_literal) (raw_string_literal) (rune_literal)] @string +(escape_sequence) @string.escape +(comment) @comment diff --git a/vrsite/assets/javascript-params.scm b/vrsite/assets/javascript-params.scm new file mode 100644 index 0000000..db300e3 --- /dev/null +++ b/vrsite/assets/javascript-params.scm @@ -0,0 +1,12 @@ +; JavaScript parameters, from tree-sitter/tree-sitter-javascript v0.25.0 +; queries/highlights-params.scm (MIT), verbatim. Its own file because upstream +; keeps it out of the TypeScript composition: there a formal_parameters child +; is always a required_parameter or optional_parameter, so these patterns are +; not merely dead but rejected by the query compiler as impossible. + +(formal_parameters [ + (identifier) @variable.parameter + (array_pattern (identifier) @variable.parameter) + (object_pattern [ + (pair_pattern value: (identifier) @variable.parameter) + (shorthand_property_identifier_pattern) @variable.parameter])]) diff --git a/vrsite/assets/javascript.scm b/vrsite/assets/javascript.scm new file mode 100644 index 0000000..a5c8f01 --- /dev/null +++ b/vrsite/assets/javascript.scm @@ -0,0 +1,52 @@ +; JavaScript highlights, from tree-sitter/tree-sitter-javascript v0.25.0 +; queries/highlights.scm (MIT). Reordered for later-patterns-win and capture +; names mapped onto zed syntax keys. Upstream's @variable.builtin and require() +; patterns are gone: they lean on (#is-not? local), which no query engine +; outside nvim evaluates. Also loaded ahead of typescript.scm, the way upstream +; composes the TypeScript highlights. + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +[";" (optional_chain) "." ","] @punctuation.delimiter +["-" "--" "-=" "+" "++" "+=" "*" "*=" "**" "**=" "/" "/=" "%" "%=" "<" "<=" + "<<" "<<=" "=" "==" "===" "!" "!=" "!==" "=>" ">" ">=" ">>" ">>=" ">>>" + ">>>=" "~" "^" "&" "|" "^=" "&=" "|=" "&&" "||" "??" "&&=" "||=" + "??="] @operator + +["as" "async" "await" "break" "case" "catch" "class" "const" "continue" + "debugger" "default" "delete" "do" "else" "export" "extends" "finally" "for" + "from" "function" "get" "if" "import" "in" "instanceof" "let" "new" "of" + "return" "set" "static" "switch" "target" "throw" "try" "typeof" "var" + "void" "while" "with" "yield"] @keyword + +[(property_identifier) (shorthand_property_identifier)] @property + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) +([(identifier) (shorthand_property_identifier) + (shorthand_property_identifier_pattern)] @constant + (#match? @constant "^[A-Z_][A-Z0-9_]+$")) + +(function_expression name: (identifier) @function) +(function_declaration name: (identifier) @function) +(method_definition name: (property_identifier) @function.method) +(pair key: (property_identifier) @function.method + value: [(function_expression) (arrow_function)]) +(assignment_expression + left: (member_expression property: (property_identifier) @function.method) + right: [(function_expression) (arrow_function)]) +(variable_declarator name: (identifier) @function + value: [(function_expression) (arrow_function)]) +(assignment_expression left: (identifier) @function + right: [(function_expression) (arrow_function)]) +(call_expression function: (identifier) @function) +(call_expression function: (member_expression + property: (property_identifier) @function.method)) + +[(this) (super)] @variable.special +(number) @number +[(true) (false) (null) (undefined)] @constant +[(string) (template_string)] @string +(escape_sequence) @string.escape +(regex) @string.regex +(template_substitution "${" @punctuation.special "}" @punctuation.special) +(comment) @comment diff --git a/vrsite/assets/json.scm b/vrsite/assets/json.scm new file mode 100644 index 0000000..e82e125 --- /dev/null +++ b/vrsite/assets/json.scm @@ -0,0 +1,13 @@ +; JSON highlights, from tree-sitter/tree-sitter-json v0.24.8 +; queries/highlights.scm (MIT). Reordered so object keys repaint the string +; they are; capture names are already zed syntax keys but for @escape. + +["{" "}" "[" "]"] @punctuation.bracket +[":" ","] @punctuation.delimiter + +(number) @number +[(null) (true) (false)] @constant +(string) @string +(pair key: (string) @string.special.key) +(escape_sequence) @string.escape +(comment) @comment diff --git a/vrsite/assets/jsx.scm b/vrsite/assets/jsx.scm new file mode 100644 index 0000000..708eba2 --- /dev/null +++ b/vrsite/assets/jsx.scm @@ -0,0 +1,16 @@ +; JSX highlights, from tree-sitter/tree-sitter-javascript v0.25.0 +; queries/highlights-jsx.scm (MIT), verbatim but for the capture rename. +; Loaded last for .jsx and .tsx; the plain typescript grammar has no JSX nodes, +; so .ts must not get this file. + +(jsx_opening_element ["<" ">"] @punctuation.bracket) +(jsx_closing_element ["</" ">"] @punctuation.bracket) +(jsx_self_closing_element ["<" "/>"] @punctuation.bracket) + +(jsx_attribute (property_identifier) @attribute) + +; lowercase names are html elements, capitalized ones are components and keep +; the @constructor javascript.scm gave them +((jsx_opening_element (identifier) @tag) (#match? @tag "^[a-z][^.]*$")) +((jsx_closing_element (identifier) @tag) (#match? @tag "^[a-z][^.]*$")) +((jsx_self_closing_element (identifier) @tag) (#match? @tag "^[a-z][^.]*$")) diff --git a/vrsite/assets/python.scm b/vrsite/assets/python.scm new file mode 100644 index 0000000..f64e673 --- /dev/null +++ b/vrsite/assets/python.scm @@ -0,0 +1,50 @@ +; Python highlights, from tree-sitter/tree-sitter-python v0.25.0 +; queries/highlights.scm (MIT). Reordered for later-patterns-win, capture names +; mapped onto zed syntax keys, and the builtin-function #match? turned into +; #any-of?. The two casing heuristics stay regexes: nothing else can express +; "CONST_CASE" and "ClassName" over bare identifiers. + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +[";" "," "." ":"] @punctuation.delimiter +["-" "-=" "!=" "*" "**" "**=" "*=" "/" "//" "//=" "/=" "&" "&=" "%" "%=" + "^" "^=" "+" "->" "+=" "<" "<<" "<<=" "<=" "<>" "=" ":=" "==" ">" ">=" + ">>" ">>=" "|" "|=" "~" "@=" "and" "in" "is" "not" "or" "is not" + "not in"] @operator + +["as" "assert" "async" "await" "break" "class" "continue" "def" "del" "elif" + "else" "except" "exec" "finally" "for" "from" "global" "if" "import" "lambda" + "nonlocal" "pass" "print" "raise" "return" "try" "while" "with" "yield" + "match" "case"] @keyword + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) +((identifier) @constant + (#match? @constant "^[A-Z][A-Z_0-9]*$")) +((identifier) @variable.special + (#any-of? @variable.special "self" "cls")) + +(type (identifier) @type) +(attribute attribute: (identifier) @property) +(function_definition name: (identifier) @function) +(call function: (identifier) @function) +(call function: (attribute attribute: (identifier) @function.method)) +((call function: (identifier) @function.builtin) + (#any-of? @function.builtin "abs" "all" "any" "ascii" "bin" "bool" + "breakpoint" "bytearray" "bytes" "callable" "chr" "classmethod" "compile" + "complex" "delattr" "dict" "dir" "divmod" "enumerate" "eval" "exec" "filter" + "float" "format" "frozenset" "getattr" "globals" "hasattr" "hash" "help" + "hex" "id" "input" "int" "isinstance" "issubclass" "iter" "len" "list" + "locals" "map" "max" "memoryview" "min" "next" "object" "oct" "open" "ord" + "pow" "print" "property" "range" "repr" "reversed" "round" "set" "setattr" + "slice" "sorted" "staticmethod" "str" "sum" "super" "tuple" "type" "vars" + "zip" "__import__")) + +[(integer) (float)] @number +[(none) (true) (false)] @constant +(string) @string +(escape_sequence) @string.escape +(interpolation "{" @punctuation.special "}" @punctuation.special) + +(decorator "@" @attribute) +(decorator [(identifier) (attribute)] @attribute) +(comment) @comment diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl index a808203..f57e454 100644 --- a/vrsite/assets/style.css.tmpl +++ b/vrsite/assets/style.css.tmpl @@ -73,7 +73,44 @@ nav > header { } nav > header a { color: var(--fg); } nav .brand { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -nav .atrev { margin: 0 0 8px; } + +/* ── the current change ──────────────────────────────────────── + Everything on screen is read at one change: the tree, the code, the + coverage, the notes, and anything you write. It says so, always. */ +.changebar { + margin-bottom: 10px; padding: 7px 8px; + border: 1px solid var(--border); border-left: 3px solid var(--accent); + border-radius: 6px; background: var(--bg); +} +.changebar.away { border-left-color: var(--note); } +.changebar .cid { display: flex; align-items: baseline; gap: 6px; } +.changebar .lbl { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; } +.changebar .cid code { font-size: 13px; font-weight: 600; color: var(--fg); } +.changebar .cid a code { color: var(--accent); } +.changebar .flags { color: var(--note); font-size: 10px; font-style: italic; margin-left: auto; } +.changebar .desc { + margin: 3px 0 0; font-size: 11.5px; color: var(--muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.changebar .counts { margin: 5px 0 0; font-size: 11px; color: var(--muted); } +.changebar .counts b { color: var(--fg); font-weight: 600; } +.changebar .away { color: var(--note); } +.changebar .scope { display: flex; gap: 0; margin-top: 7px; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; } +.changebar .scope a { + flex: 1; text-align: center; padding: 2px 4px; + font-size: 11px; color: var(--muted); text-decoration: none; +} +.changebar .scope a:hover { background: var(--hover); color: var(--fg); } +.changebar .scope a.on { background: var(--hover); color: var(--fg); font-weight: 600; } +.changebar .revjump { margin-top: 6px; } +.changebar .revjump input { width: 100%; font-family: ui-monospace, Menlo, monospace; font-size: 11px; } +.changebar .back { margin: 6px 0 0; font-size: 11px; } + +/* a note written at another change: it pins to line numbers that have moved */ +.note.away { border-style: dashed; opacity: 0.82; } +.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 { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; @@ -183,6 +220,8 @@ ol.code li { white-space: pre; min-height: 1lh; padding-right: 16px; + /* a #L42 jump must clear the sticky header, or the line lands under it */ + scroll-margin-top: 76px; } ol.code li::before { content: counter(ln); @@ -222,8 +261,62 @@ aside summary { cursor: pointer; color: var(--muted); } .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; } .notecols { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0 14px; } +/* ── conversation ──────────────────────────────────────────── */ +.convpage { padding: 0 16px 40vh; max-width: 1100px; } +.turn { + display: grid; grid-template-columns: 12ch 8ch minmax(0, 1fr); + gap: 0 10px; align-items: baseline; + padding: 2px 0 2px 8px; border-left: 2px solid transparent; +} +.turn .role { + color: var(--muted); font-size: 11px; text-align: right; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.turn time { color: var(--ln); font-size: 11px; font-variant-numeric: tabular-nums; } +.turn p { margin: 0; line-height: 1.5; white-space: pre-wrap; } +.turn .what { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.t-say { padding: 8px 0 8px 8px; } +.t-user { border-left-color: var(--accent); } +.t-user .role, .t-user p { color: var(--fg); } +.t-assistant .role { color: var(--cov); } +.t-thinking p { color: var(--muted); font-style: italic; } +.t-tool .role { color: var(--note); } +.t-result .what { color: var(--muted); } +.t-result.bad .what { color: var(--note); } +.t-vr { border-left-color: var(--cov); background: color-mix(in srgb, var(--cov) 7%, transparent); } +.t-vr .role { color: var(--cov); font-weight: 600; } +/* A note is what you came to the conversation to find, so it breaks the run + of log lines: the note color's rail, a tinted band, a card instead of a + line. scroll-margin clears the sticky header — the anchored one is + scrolled to on load. */ +.t-note { + padding: 8px 0 8px 8px; + border-left-color: var(--note); + background: color-mix(in srgb, var(--note) 7%, transparent); +} +.t-note .role { color: var(--note); font-weight: 600; } +.t-note .role::before { content: "◆ "; font-size: 9px; vertical-align: 1px; } +.t-note .note { max-width: 640px; margin: 0; scroll-margin-top: 96px; } +.t-note.here { background: color-mix(in srgb, var(--accent) 12%, transparent); } +.t-note.here .note { border-color: var(--accent); border-left-color: var(--accent); } +/* the whole card is the way back to the code; the links inside still work */ +.notewrap { cursor: pointer; } +.notewrap:hover .note, .notewrap:focus-visible .note { + border-color: var(--accent); background: var(--hover); +} +.notewrap:focus-visible { outline: none; } +.notewrap[data-href] .note::after { + content: "open in source →"; display: block; + margin-top: 6px; color: var(--accent); font-size: 11px; opacity: 0; +} +.notewrap:hover .note::after, .notewrap:focus-visible .note::after { opacity: 1; } + /* ── live activity ─────────────────────────────────────────── */ .activity { margin-bottom: 10px; } .actlist { display: flex; flex-direction: column; gap: 3px; } @@ -279,9 +372,10 @@ aside summary { cursor: pointer; color: var(--muted); } .asidehead { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } aside .meta { font-size: 11px; } -/* selection while picking lines to comment on */ -ol.code li.sel { background: color-mix(in srgb, var(--accent) 16%, transparent); } -ol.code li.sel::before { color: var(--accent); } +/* lines picked to comment on, and lines you arrived at from a note */ +ol.code li.sel, ol.code li.focus { background: color-mix(in srgb, var(--accent) 16%, transparent); } +ol.code li.sel::before, ol.code li.focus::before { color: var(--accent); } +ol.code li.focus { box-shadow: inset 3px 0 var(--accent); } /* ── jj ────────────────────────────────────────────────────── */ .jjform { display: flex; gap: 6px; align-items: center; padding: 10px 0; } diff --git a/vrsite/assets/typescript.scm b/vrsite/assets/typescript.scm new file mode 100644 index 0000000..96cf9c3 --- /dev/null +++ b/vrsite/assets/typescript.scm @@ -0,0 +1,15 @@ +; TypeScript highlights, from tree-sitter/tree-sitter-typescript v0.23.2 +; queries/highlights.scm (MIT). A supplement loaded after javascript.scm, as +; upstream composes it, so these patterns win. Upstream's +; ((identifier) @type (#match? "^[A-Z]")) is dropped: javascript.scm already +; paints capitalized identifiers as @constructor, and repainting every one of +; them as a type costs a second regex per identifier for a worse answer. + +["abstract" "declare" "enum" "implements" "interface" "keyof" "namespace" + "private" "protected" "public" "type" "readonly" "override" + "satisfies"] @keyword + +[(type_identifier) (predefined_type)] @type +(type_arguments "<" @punctuation.bracket ">" @punctuation.bracket) +(required_parameter (identifier) @variable.parameter) +(optional_parameter (identifier) @variable.parameter) diff --git a/vrsite/bench_test.go b/vrsite/bench_test.go index 8950b8f..c3eac0c 100644 --- a/vrsite/bench_test.go +++ b/vrsite/bench_test.go @@ -55,14 +55,163 @@ const MAX_ENTRIES: usize = 4096; static GREETING: &str = "hello \"quoted\" <world> & friends"; `) -func synthRust(size int) []byte { +// One chunk per language whose query leans on a predicate, to keep an eye on +// what predicates cost: go's builtin list is an #any-of? (a byte compare per +// call), while python, javascript and C run a casing regex over every +// identifier they see. +var langChunks = []struct { + path string + src []byte +}{ + {"bench.go", []byte(`// Package cache keeps entries with hit counts. +package cache + +import ( + "fmt" + "sync" +) + +// Entry is one cached value and its hit count. +type Entry struct { + Key string + Value []byte + hits uint64 + tags map[string][]byte +} + +const MaxEntries = 4096 + +func NewEntry(key string, value []byte) *Entry { + return &Entry{Key: key, Value: value, tags: make(map[string][]byte)} +} + +// Touch records a hit and returns the running total. +func (e *Entry) Touch(mu *sync.Mutex) uint64 { + mu.Lock() + defer mu.Unlock() + e.hits++ + if e.hits%100 == 0 { + fmt.Printf("entry %q hit %d times \"escaped\"\n", e.Key, e.hits) + } + return e.hits +} + +func (e *Entry) Lookup(keys []string) ([]byte, bool) { + for _, k := range keys { + if v, ok := e.tags[k]; ok && len(v) > 0 { + return v, true + } + } + return nil, false +} +`)}, + {"bench.py", []byte(`"""Module documentation with some text.""" +import sys +from collections import OrderedDict + +MAX_ENTRIES = 4096 + + +class CacheEntry: + """A cached entry with a hit count.""" + + def __init__(self, key, value): + self.key = key + self.value = value + self.hits = 0 + self.tags = OrderedDict() + + @property + def stale(self): + return self.hits > MAX_ENTRIES + + def touch(self): + self.hits += 1 + if self.hits % 100 == 0: + print(f"entry {self.key} hit {self.hits} times \"escaped\"", file=sys.stderr) + return self.hits + + def lookup(self, keys): + for k in keys: + if k in self.tags: + return self.tags[k], True + return None, False +`)}, + {"bench.js", []byte(`// Cache entries with hit counts. +import { EventEmitter } from "node:events"; + +const MAX_ENTRIES = 4096; + +export class CacheEntry extends EventEmitter { + constructor(key, value) { + super(); + this.key = key; + this.value = value; + this.hits = 0; + this.tags = new Map(); + } + + get stale() { + return this.hits > MAX_ENTRIES; + } + + touch() { + this.hits += 1; + if (this.hits % 100 === 0) { + console.log(` + "`entry ${this.key} hit ${this.hits} times \"escaped\"`" + `); + } + return this.hits; + } + + lookup(keys) { + return keys.map((k) => this.tags.get(k)).find((v) => v !== undefined) ?? null; + } +} +`)}, + {"bench.c", []byte(`/* Cache entries with hit counts. */ +#include <stdio.h> +#include <string.h> + +#define MAX_ENTRIES 4096 + +struct cache_entry { + const char *key; + unsigned char *value; + unsigned long hits; +}; + +static struct cache_entry *entry_new(const char *key, unsigned char *value) { + static struct cache_entry e; + e.key = key; + e.value = value; + e.hits = 0; + return &e; +} + +unsigned long entry_touch(struct cache_entry *e) { + e->hits++; + if (e->hits % 100 == 0) { + fprintf(stderr, "entry %s hit %lu times \"escaped\"\n", e->key, e->hits); + } + return e->hits; +} + +int entry_lookup(struct cache_entry *e, const char *key) { + return e->key != NULL && strcmp(e->key, key) == 0; +} +`)}, +} + +func synth(chunk []byte, size int) []byte { var b bytes.Buffer for b.Len() < size { - b.Write(rustChunk) + b.Write(chunk) } return b.Bytes() } +func synthRust(size int) []byte { return synth(rustChunk, size) } + var sizes = []int{128 << 10, 1 << 20, 4 << 20} func benchHL(b *testing.B) *highlighter { @@ -85,6 +234,21 @@ func BenchmarkClassify(b *testing.B) { } } +// the same work for the languages whose queries evaluate predicates +func BenchmarkClassifyLangs(b *testing.B) { + hl := benchHL(b) + for _, c := range langChunks { + src := synth(c.src, 1<<20) + b.Run(c.path, func(b *testing.B) { + b.SetBytes(int64(len(src))) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + hl.classify(c.path, src) + } + }) + } +} + // classify + full per-line HTML emit func BenchmarkRender(b *testing.B) { hl := benchHL(b) diff --git a/vrsite/go.mod b/vrsite/go.mod index bf04dfe..cb9dd9e 100644 --- a/vrsite/go.mod +++ b/vrsite/go.mod @@ -4,7 +4,15 @@ go 1.26.5 require ( 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 + github.com/tree-sitter/tree-sitter-cpp v0.23.4 + github.com/tree-sitter/tree-sitter-go v0.25.0 + github.com/tree-sitter/tree-sitter-javascript v0.25.0 + github.com/tree-sitter/tree-sitter-json v0.24.8 + 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 ) require github.com/mattn/go-pointer v0.0.1 // indirect diff --git a/vrsite/go.sum b/vrsite/go.sum index b2466d9..af50473 100644 --- a/vrsite/go.sum +++ b/vrsite/go.sum @@ -8,29 +8,33 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU= github.com/tree-sitter/go-tree-sitter v0.25.0/go.mod h1:r77ig7BikoZhHrrsjAnv8RqGti5rtSyvDHPzgTPsUuU= -github.com/tree-sitter/tree-sitter-c v0.23.4 h1:nBPH3FV07DzAD7p0GfNvXM+Y7pNIoPenQWBpvM++t4c= -github.com/tree-sitter/tree-sitter-c v0.23.4/go.mod h1:MkI5dOiIpeN94LNjeCp8ljXN/953JCwAby4bClMr6bw= +github.com/tree-sitter/tree-sitter-bash v0.25.1 h1:ZD3MK4oDB5lAsFztqbdcyYEd24pxDtx3g9UOWA062rE= +github.com/tree-sitter/tree-sitter-bash v0.25.1/go.mod h1:AksQ6zE+sP9hnp7mKTMT7Q+CwpthV7VGQLXvweVXz9U= +github.com/tree-sitter/tree-sitter-c v0.24.2 h1:nW+M6BnPUa/fBwks8nqf1NiVvu7nltaC+5bR/lTtJCs= +github.com/tree-sitter/tree-sitter-c v0.24.2/go.mod h1:/SpJlv2BuiCgFA5xvtgukFGi51WxctByPUGDxPl60fc= github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJTJFCyFIeZU/8w= github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8= github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg= github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ= -github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA= -github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040= +github.com/tree-sitter/tree-sitter-go v0.25.0 h1:cEB0Q3LHgZtS+ECHx9wcP7AwzoOddJFQCVmytX42cVU= +github.com/tree-sitter/tree-sitter-go v0.25.0/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040= github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc= github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og= github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig= github.com/tree-sitter/tree-sitter-java v0.23.5/go.mod h1:NRKlI8+EznxA7t1Yt3xtraPk1Wzqh3GAIC46wxvc320= -github.com/tree-sitter/tree-sitter-javascript v0.23.1 h1:1fWupaRC0ArlHJ/QJzsfQ3Ibyopw7ZfQK4xXc40Zveo= -github.com/tree-sitter/tree-sitter-javascript v0.23.1/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs= +github.com/tree-sitter/tree-sitter-javascript v0.25.0 h1:ZkWETb66/w8cc13yhfnNuHOLDQWl3BnKlH6f9AdR88c= +github.com/tree-sitter/tree-sitter-javascript v0.25.0/go.mod h1:lmGD1EJdCA+v0S1u2fFgepMg/opzSg/4pgFym2FPGAs= github.com/tree-sitter/tree-sitter-json v0.24.8 h1:tV5rMkihgtiOe14a9LHfDY5kzTl5GNUYe6carZBn0fQ= github.com/tree-sitter/tree-sitter-json v0.24.8/go.mod h1:F351KK0KGvCaYbZ5zxwx/gWWvZhIDl0eMtn+1r+gQbo= github.com/tree-sitter/tree-sitter-php v0.23.11 h1:iHewsLNDmznh8kgGyfWfujsZxIz1YGbSd2ZTEM0ZiP8= github.com/tree-sitter/tree-sitter-php v0.23.11/go.mod h1:T/kbfi+UcCywQfUNAJnGTN/fMSUjnwPXA8k4yoIks74= -github.com/tree-sitter/tree-sitter-python v0.23.6 h1:qHnWFR5WhtMQpxBZRwiaU5Hk/29vGju6CVtmvu5Haas= -github.com/tree-sitter/tree-sitter-python v0.23.6/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM= +github.com/tree-sitter/tree-sitter-python v0.25.0 h1:O6XD9v8U1LOcRc3cNj9nM7XufrtEBezE6VrpRrHZDf0= +github.com/tree-sitter/tree-sitter-python v0.25.0/go.mod h1:cpdthSy/Yoa28aJFBscFHlGiU+cnSiSh1kuDVtI8YeM= github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV6z8Qw8ai+72bYo= github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA= github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvhW10DVyO+D0z/W0= 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= 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/highlight_test.go b/vrsite/highlight_test.go new file mode 100644 index 0000000..b565785 --- /dev/null +++ b/vrsite/highlight_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "strings" + "sync" + "testing" + + sitter "github.com/tree-sitter/go-tree-sitter" +) + +func testHighlighter(t *testing.T) *highlighter { + t.Helper() + return newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light")) +} + +// One snippet per language, mapping a piece of its source to the class the One +// theme implies for it. Everything asserted here is a key both One Dark and +// One Light colour differently from plain foreground. +var samples = []struct { + path string + src string + want map[string]string +}{ + {"a.rs", "// note\nfn main() { let s = \"hi\"; }\n", + map[string]string{"fn": "keyword", `"hi"`: "string", "// note": "comment", "main": "function"}}, + {"a.go", "// note\nfunc main() { s := \"hi\"; _ = s }\n", + map[string]string{"func": "keyword", `"hi"`: "string", "// note": "comment", "main": "function"}}, + {"a.py", "# note\nclass Frame:\n def draw(self):\n widget = Widget\n return len(\"hi\")\n", + map[string]string{"class": "keyword", `"hi"`: "string", "# note": "comment", + "draw": "function", "len": "function", "Frame": "constructor", + "Widget": "constructor", "self": "variable-special", "widget": ""}}, + {"a.js", "// note\nfunction main() { const s = \"hi\"; return s; }\n", + map[string]string{"function": "keyword", `"hi"`: "string", "// note": "comment", "main": "function"}}, + {"a.ts", "// note\nfunction main(n: number): string { return \"hi\"; }\n", + map[string]string{"function": "keyword", `"hi"`: "string", "// note": "comment", + "number": "type", "main": "function"}}, + {"a.tsx", "// note\nconst e = <div id=\"x\" />;\n", + map[string]string{"const": "keyword", `"x"`: "string", "// note": "comment", "id": "attribute"}}, + {"a.c", "// note\n#include <stdio.h>\nint main(void) { const char *s = \"hi\"; return MAX; }\n", + map[string]string{"const": "keyword", `"hi"`: "string", "// note": "comment", + "int": "type", "main": "function", "#include": "preproc", + "<stdio.h>": "string", "MAX": "constant"}}, + {"a.cc", "// note\nnamespace n { class C { const char *s = \"hi\"; }; }\n", + map[string]string{"namespace": "keyword", `"hi"`: "string", "// note": "comment", "char": "type"}}, + // .h goes to the C++ grammar, but c.scm's patterns still have to reach it + {"a.h", "// note\ntemplate <class T> struct Box { T v; };\n", + map[string]string{"template": "keyword", "struct": "keyword", "// note": "comment", "T": "type"}}, + {"a.json", "{\n \"a\": \"hi\",\n \"b\": 12\n}\n", + map[string]string{`"hi"`: "string", `"a"`: "string-special", "12": "number"}}, + {"a.sh", "# note\nif true; then\n ls -l \"$dir\"\nfi\n", + map[string]string{"if": "keyword", "# note": "comment", "ls": "function", + "-l": "constant", "dir": "property"}}, +} + +// classOf reports the single class covering every byte of text's first +// occurrence in src, or "" when the run is unclassified or not uniform. +func classOf(hl *highlighter, path, src, text string) string { + classes := hl.classify(path, []byte(src)) + i := strings.Index(src, text) + if i < 0 { + return "" + } + for j := i + 1; j < i+len(text); j++ { + if classes[j] != classes[i] { + return "" + } + } + return hl.classNames[classes[i]] +} + +func TestClassifyPerLanguage(t *testing.T) { + hl := testHighlighter(t) + for _, s := range samples { + for text, want := range s.want { + if got := classOf(hl, s.path, s.src, text); got != want { + t.Errorf("%s: %q classified %q, want %q", s.path, text, got, want) + } + } + } +} + +func TestClassifyUnknownExtension(t *testing.T) { + hl := testHighlighter(t) + // text that every grammar would happily highlight, under paths none claims + src := []byte("// note\nfn main() { let s = \"hi\"; }\n") + for _, path := range []string{"notes.txt", "README.md", "Makefile", "a/b.unknown", ".gitignore", ""} { + classes := hl.classify(path, src) + if len(classes) != len(src) { + t.Fatalf("%q: got %d classes for %d bytes", path, len(classes), len(src)) + } + for i, c := range classes { + if c != 0 { + t.Fatalf("%q: byte %d classified %q, want plain text", path, i, hl.classNames[c]) + } + } + } +} + +// Every predicate a vendored query uses has to be one go-tree-sitter actually +// evaluates in QueryMatches.Next. Anything else lands in these two buckets, +// where it is parsed, ignored, and silently paints matches it should not. +func TestQueriesUseOnlyEvaluatedPredicates(t *testing.T) { + hl := testHighlighter(t) + for _, g := range grammars { + l := hl.byExt[g.exts[0]] + name := strings.Join(g.scms, "+") + for i := uint(0); i < l.query.PatternCount(); i++ { + for _, p := range l.query.GeneralPredicates(i) { + t.Errorf("%s pattern %d: #%s is not evaluated", name, i, p.Operator) + } + for _, p := range l.query.PropertyPredicates(i) { + t.Errorf("%s pattern %d: #is? %s is not evaluated", name, i, p.Property.Key) + } + } + } +} + +// A query that fails to compile is fatal at startup, so report the real error +// here rather than letting the process exit out from under the test binary. +func TestQueriesCompile(t *testing.T) { + for _, g := range grammars { + var scm strings.Builder + for _, name := range g.scms { + b, err := tfs.ReadFile("assets/" + name + ".scm") + if err != nil { + t.Fatal(err) + } + scm.Write(b) + } + if _, err := sitter.NewQuery(sitter.NewLanguage(g.lang()), scm.String()); err != nil { + t.Errorf("%s: %v", strings.Join(g.scms, "+"), err) + } + } +} + +// Class names are theme syntax keys, so css.go's .s-<key> rules — generated +// from the union of both themes' keys — already cover every new language. +func TestClassNamesHaveTheme(t *testing.T) { + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + hl := newHighlighter(dark, light) + styled := map[string]bool{} + for _, th := range []*Theme{dark, light} { + for k := range th.Syntax { + styled[strings.ReplaceAll(k, ".", "-")] = true + } + } + for _, cls := range hl.classNames[1:] { + if !styled[cls] { + t.Errorf("class %q has no .s-%s rule in either theme", cls, cls) + } + } +} + +func TestClassifyIsRaceFree(t *testing.T) { + hl := testHighlighter(t) + want := make([][]int16, len(samples)) + for i, s := range samples { + want[i] = hl.classify(s.path, []byte(s.src)) + } + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for i, s := range samples { + got := hl.classify(s.path, []byte(s.src)) + for j := range got { + if got[j] != want[i][j] { + t.Errorf("%s: byte %d classified %d, want %d", s.path, j, got[j], want[i][j]) + return + } + } + } + }() + } + wg.Wait() +} diff --git a/vrsite/jj.go b/vrsite/jj.go index 55f4795..bb95d83 100644 --- a/vrsite/jj.go +++ b/vrsite/jj.go @@ -89,6 +89,24 @@ func resolveChange(repo, rev string) (string, error) { return id, nil } +// changeBrief resolves a revset to the one change it names, with everything a +// page says about it. One call answers both "which change" and "which commit +// is under it right now" — the second is what content is cached against, since +// a change id is a pointer and the commit beneath it is rewritten on every +// edit. +func changeBrief(repo, rev string) (Row, error) { + rows, err := LogRows(repo, rev, "", 1) + if err != nil { + return Row{}, err + } + for _, r := range rows { + if !r.Blank() { + return r, nil + } + } + return Row{}, fmt.Errorf("revset %q matches no change", rev) +} + func fileList(repo, rev string) ([]string, error) { out, err := jjRun(repo, "file", "list", "-r", rev) if err != nil { diff --git a/vrsite/main.go b/vrsite/main.go index 0a525be..5f9b1ef 100644 --- a/vrsite/main.go +++ b/vrsite/main.go @@ -21,6 +21,7 @@ import ( "os" "os/user" "path/filepath" + "strings" "time" ) @@ -32,6 +33,45 @@ func fatal(format string, a ...any) { os.Exit(1) } +const usageText = `vrsite — the reading room for vr trace logs. + +vr logs every file a coding agent reads, greps and notes, one JSON line each, +keyed to the jj change the code was read at. vrsite serves that log: the repo +tree with per-agent read coverage, syntax-highlighted sources, the notes agents +left, the conversation each note was written in, and jj queries over the repo. + +usage: + vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME] + serve it, at http://127.0.0.1:4200 by default + vrsite -log FILE -repo DIR -out DIR [-watch] + write a static copy instead — no server, so no comments, no live + activity, no jj queries; the focus toggle, filters and file finder + still work, and it holds the one change the log points at + +starting on a new project: + 1. the project must be a jj repo — in an existing git one: + jj git init --colocate + 2. give the agents vr and one log for the repo: + export VR_LOG=$PWD/vr-log.jsonl + and tell them to read with it; constrain/AGENTS.md in the vr repo is a + drop-in brief that does, and constrain/ can also enforce it + 3. let them work, then: + vrsite -log $PWD/vr-log.jsonl -repo $PWD + +A log belongs to one repository. Entries carry the jj change id they were +recorded at, and vrsite reads the code at those changes, so a log from another +repo has nothing to show here. Point -repo at the repository the agents ran in. + +Notes written in the browser are appended to the same log, under -user, at the +change being read. Nothing else is ever written: the repository is only read, +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. + +flags: +` + func main() { logPath := flag.String("log", "", "vr trace log (jsonl)") repo := flag.String("repo", "", "jj repo the trace was recorded in") @@ -43,13 +83,25 @@ func main() { lightPath := flag.String("light-theme", defaultTheme("light"), "zed theme json for the light scheme (default: discovered, else embedded One Light)") lightName := flag.String("light-theme-name", "", "theme name inside the light family") title := flag.String("title", "vr trace", "site title") + roots := flag.String("transcripts", strings.Join(defaultTranscriptRoots(), ","), + "comma-separated roots to look for harness session transcripts in") watch := flag.Bool("watch", false, "with -out: watch the trace log and rebuild after changes settle") debounce := flag.Duration("debounce", 10*time.Minute, "quiet period before a watched rebuild") + flag.Usage = func() { + fmt.Fprint(flag.CommandLine.Output(), usageText) + flag.PrintDefaults() + } flag.Parse() if *logPath == "" || *repo == "" { - fatal("usage: vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME]\n" + - " vrsite -log FILE -repo DIR -out DIR [-watch] [-debounce 10m]\n" + - " shared: [-theme zed.json] [-theme-name N] [-light-theme f] [-title T]") + missing := "-log and -repo are required" + if *logPath != "" { + missing = "-repo is required: the repository the log was recorded in" + } else if *repo != "" { + missing = "-log is required: the jsonl file vr appends to" + } + fmt.Fprintf(os.Stderr, "vrsite: %s\n\n", missing) + flag.Usage() + os.Exit(2) } repoAbs, err := filepath.Abs(*repo) if err != nil { @@ -65,7 +117,7 @@ func main() { if *watch { fatal("-watch is for -out builds; the served app already follows the log") } - serve(*addr, *logPath, repoAbs, *title, username(*who), theme, light) + serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light) return } build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light) } diff --git a/vrsite/model.go b/vrsite/model.go index 2cafd75..678d5ee 100644 --- a/vrsite/model.go +++ b/vrsite/model.go @@ -74,22 +74,37 @@ type Site struct { Title string Repo string Change string // the change most entries were logged at + Only string // the change this view is scoped to ("" = the whole log) Agents []*Agent Files map[string]*File // traced files Order []string // traced paths, sorted All []*File // every file in the repo at the site change - Notes []*Note - Entries []Entry - Theme *Theme // dark + Notes []*Note // in scope + Entries []Entry // the whole log, scope or no scope + Theme *Theme // dark Light *Theme + + // counted over the whole log, so a scoped view can still say what it is + // not showing + NotesTotal int + NotesAt map[string]int // change -> notes recorded there + Votes map[string]int // change -> entries recorded there + Ranked []string // changes, most-logged first } // buildModel projects log entries onto agents, files and notes. It never // touches the repo, so it is cheap enough to redo on every log change. -func buildModel(title string, entries []Entry) *Site { - s := &Site{Title: title, Files: map[string]*File{}, Entries: entries} +// +// only, when set, keeps just what was recorded at that change: a read logged +// against another change points at line numbers that have since moved, so a +// view of one change must not inherit them. Two things stay whole-log +// regardless — agent colors, so they do not shuffle when the scope changes, +// and note ids, so a link to a note survives it. +func buildModel(title string, entries []Entry, only string) *Site { + s := &Site{Title: title, Only: only, Files: map[string]*File{}, Entries: entries, + NotesAt: map[string]int{}, Votes: map[string]int{}} agents := map[string]*Agent{} - changeVotes := map[string]int{} + noteN := 0 for _, e := range entries { if e.Agent == "" { e.Agent = "unknown" @@ -105,7 +120,15 @@ func buildModel(title string, entries []Entry) *Site { s.Agents = append(s.Agents, a) } if e.Change != "" { - changeVotes[e.Change]++ + s.Votes[e.Change]++ + } + if e.Op == "note" { + noteN++ + s.NotesTotal++ + s.NotesAt[e.Change]++ + } + if only != "" && e.Change != only { + continue } switch e.Op { case "read": @@ -125,7 +148,7 @@ func buildModel(title string, entries []Entry) *Site { case "note": a.Notes++ at := parseTime(e.Time) - n := &Note{ID: len(s.Notes) + 1, NoteID: e.NoteID, Agent: a, Session: e.Session, + 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, At: at, When: stamp(at), Ago: ago(at)} @@ -136,10 +159,19 @@ func buildModel(title string, entries []Entry) *Site { } } } - for c, n := range changeVotes { - if s.Change == "" || n > changeVotes[s.Change] { - s.Change = c + for c := range s.Votes { + s.Ranked = append(s.Ranked, c) + } + // most-logged first; ties by id so the pick is stable across rebuilds + sort.Slice(s.Ranked, func(i, j int) bool { + a, b := s.Ranked[i], s.Ranked[j] + if s.Votes[a] != s.Votes[b] { + return s.Votes[a] > s.Votes[b] } + return a < b + }) + if len(s.Ranked) > 0 { + s.Change = s.Ranked[0] } // drop grep-only "files" that are really path filters (dirs, non-files) for p, f := range s.Files { diff --git a/vrsite/page.go b/vrsite/page.go index 71a8343..2974eec 100644 --- a/vrsite/page.go +++ b/vrsite/page.go @@ -16,16 +16,29 @@ import ( type page struct { Site *Site Title string - Kind string // index | file | files | notes | jj | change + Kind string // index | file | files | notes | jj | change | session Root string // static: relative prefix; live: "/" Live bool - Rev string // revision being browsed ("" = the site change) - ChangeID string // resolved change id for Rev + Rev string // revision being browsed ("" = the change the log points at) + ChangeID string // resolved change id for Rev — what pages name and link + Commit string // the commit under it — what content is read and cached at + Default string // the change the log points at + Path string // this request's path, so the change bar can re-aim it Current string // current file path, for the tree User string // who web comments are attributed to - Tree template.HTML - File *File + // what this view is scoped to. Scope is "change" (only what was recorded + // at ChangeID) or "all"; ScopeEmpty means "change" was asked for but + // nothing was ever recorded there, so the whole log is showing instead. + Scope string + ScopeEmpty bool + Here int // notes recorded at this change + Elsewhere int // notes recorded at any other change + ChangeInfo *Row + + Tree template.HTML + File *File + Files []*File // every file at this change, for counts Notes []*Note Filter NoteFilter @@ -36,6 +49,7 @@ type page struct { JJ *JJView Change *Change + Conv *Conv Err string } @@ -49,15 +63,54 @@ type JJView struct { Err string } -func (p *page) revQuery(first bool) string { - if p.Rev == "" || p.Rev == "@" { +// params carries the two things every link must not lose: which change you +// are looking at, and how wide the view is. +func (p *page) params() string { return p.paramsWith(p.Scope) } + +func (p *page) paramsWith(scope string) string { + var q []string + // "@" is a revset like any other now that the default change is the log's, + // so dropping it here would slide the reader off the change they picked + if p.Rev != "" { + q = append(q, "rev="+url.QueryEscape(p.Rev)) + } + if scope == "all" { + q = append(q, "scope=all") + } + if len(q) == 0 { return "" } - sep := "&" - if first { - sep = "?" + return "?" + strings.Join(q, "&") +} + +// ScopeHref is this same page seen with the other scope. +func (p *page) ScopeHref(scope string) string { + path := p.Path + if path == "" { + path = "/" + } + return path + p.paramsWith(scope) +} + +// BackHref is this page again at the change the log points at. +func (p *page) BackHref() string { + q := "" + if p.Scope == "all" { + q = "?scope=all" + } + return p.JumpPath() + q +} + +// JumpPath is where the change picker submits: your own page when it is one +// that can be read at any change, the file tree otherwise. +func (p *page) JumpPath() string { + switch p.Kind { + case "index", "file", "files", "notes": + if p.Path != "" { + return p.Path + } } - return sep + "rev=" + url.QueryEscape(p.Rev) + return "/files" } // Href maps a logical destination to a URL for this page's mode. Kinds the @@ -70,19 +123,21 @@ func (p *page) Href(kind string, arg ...string) string { if p.Live { switch kind { case "": - return "/" + p.revQuery(true) + return "/" + p.params() case "notes": - return "/notes" + return "/notes" + p.params() case "files": - return "/files" + p.revQuery(true) + return "/files" + p.params() case "tree": - return "/tree" + p.revQuery(true) + return "/tree" + p.params() case "jj": return "/jj" case "code": - return "/code/" + escPath(a) + p.revQuery(true) + return "/code/" + escPath(a) + p.params() case "change": return "/change/" + a + case "session": + return "/session/" + escPath(a) case "asset": return "/" + a } @@ -118,12 +173,31 @@ func escPath(p string) string { return strings.Join(parts, "/") } +// ConvHref opens the conversation a note was written in, anchored on it. It +// needs the transcripts on this machine, so the static export has none — and +// the conversation itself does not link back to where you already are. +func (p *page) ConvHref(n *Note) string { + if !p.Live || p.Kind == "session" || n.Session == "" || safeSession(n.Session) != nil { + return "" + } + id := strconv.Itoa(n.ID) + return p.Href("session", n.Session) + "?note=" + id + "#note-" + id +} + // 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). func (p *page) NoteHref(n *Note) string { if n.File == "" { return p.Href("notes") } + id := strconv.Itoa(n.ID) h := p.Href("code", n.File) + sep := "?" + if strings.Contains(h, "?") { + sep = "&" + } + h += sep + "note=" + id if n.Start > 0 { h += "#L" + strconv.Itoa(n.Start) } diff --git a/vrsite/render.go b/vrsite/render.go index de21fc5..f18c3a8 100644 --- a/vrsite/render.go +++ b/vrsite/render.go @@ -5,12 +5,22 @@ package main import ( "bytes" "html/template" + "path/filepath" "sort" "strconv" "strings" + "unsafe" sitter "github.com/tree-sitter/go-tree-sitter" + tsbash "github.com/tree-sitter/tree-sitter-bash/bindings/go" + tsc "github.com/tree-sitter/tree-sitter-c/bindings/go" + tscpp "github.com/tree-sitter/tree-sitter-cpp/bindings/go" + tsgo "github.com/tree-sitter/tree-sitter-go/bindings/go" + tsjs "github.com/tree-sitter/tree-sitter-javascript/bindings/go" + tsjson "github.com/tree-sitter/tree-sitter-json/bindings/go" + tspython "github.com/tree-sitter/tree-sitter-python/bindings/go" tsrust "github.com/tree-sitter/tree-sitter-rust/bindings/go" + tsts "github.com/tree-sitter/tree-sitter-typescript/bindings/go" ) // Lines per content-visibility chunk: the browser skips layout and paint of @@ -179,10 +189,41 @@ func escapeTo(b *strings.Builder, s []byte) { // ── highlighting ────────────────────────────────────────────────────────── -type highlighter struct { +// grammars lists every language the site can highlight: the extensions it +// claims, the vendored queries that paint it, and the cgo grammar. Query files +// concatenate the way upstream composes them — C's patterns underpin C++, +// JavaScript's underpin TypeScript — and the concatenation keeps the +// later-patterns-win order, so the supplement wins where both match. +var grammars = []struct { + exts []string + scms []string + lang func() unsafe.Pointer +}{ + {[]string{".rs"}, []string{"rust"}, tsrust.Language}, + {[]string{".go"}, []string{"go"}, tsgo.Language}, + {[]string{".py", ".pyi"}, []string{"python"}, tspython.Language}, + {[]string{".js", ".mjs", ".cjs", ".jsx"}, []string{"javascript", "javascript-params", "jsx"}, tsjs.Language}, + {[]string{".ts", ".mts", ".cts"}, []string{"javascript", "typescript"}, tsts.LanguageTypescript}, + {[]string{".tsx"}, []string{"javascript", "typescript", "jsx"}, tsts.LanguageTSX}, + {[]string{".c"}, []string{"c"}, tsc.Language}, + // .h is C++'s upstream claim, and the C++ grammar is a superset: C headers + // parse the same under it, C++ headers only parse under it + {[]string{".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"}, []string{"c", "cpp"}, tscpp.Language}, + {[]string{".json"}, []string{"json"}, tsjson.Language}, + {[]string{".sh", ".bash"}, []string{"bash"}, tsbash.Language}, +} + +// language is one grammar's share of the highlighter. Everything here is built +// at startup and never written again, which is what lets classify run from any +// number of goroutines at once. +type language struct { lang *sitter.Language query *sitter.Query captureCls []int16 // capture index -> class id +} + +type highlighter struct { + byExt map[string]*language classNames []string } @@ -213,72 +254,91 @@ func nearFg(t *Theme, name string) bool { } func newHighlighter(dark, light *Theme) *highlighter { - h := &highlighter{classNames: []string{""}} - h.lang = sitter.NewLanguage(tsrust.Language()) - scm, _ := tfs.ReadFile("assets/rust.scm") - q, qerr := sitter.NewQuery(h.lang, string(scm)) - if qerr != nil { - fatal("rust query: %v", qerr) - } - h.query = q - for _, name := range q.CaptureNames() { - // skip captures that both themes paint (nearly) as foreground — - // punctuation and friends are the bulk of all spans - if nearFg(dark, name) && nearFg(light, name) { - h.captureCls = append(h.captureCls, 0) - continue + h := &highlighter{byExt: map[string]*language{}, classNames: []string{""}} + for _, g := range grammars { + var scm strings.Builder + for _, name := range g.scms { + b, err := tfs.ReadFile("assets/" + name + ".scm") + if err != nil { + fatal("%v", err) + } + scm.Write(b) } - key := resolveKey(dark, name) - if key == "" { - key = resolveKey(light, name) + l := &language{lang: sitter.NewLanguage(g.lang())} + q, qerr := sitter.NewQuery(l.lang, scm.String()) + if qerr != nil { + fatal("%s query: %v", strings.Join(g.scms, "+"), qerr) } - cls := strings.ReplaceAll(key, ".", "-") - id := int16(0) - for i, n := range h.classNames { - if n == cls { - id = int16(i) - } + l.query = q + for _, name := range q.CaptureNames() { + l.captureCls = append(l.captureCls, h.classFor(dark, light, name)) } - if id == 0 { - h.classNames = append(h.classNames, cls) - id = int16(len(h.classNames) - 1) + for _, ext := range g.exts { + h.byExt[ext] = l } - h.captureCls = append(h.captureCls, id) } return h } +// classFor interns the class id of a capture name, which doubles as a zed +// theme syntax key. Captures both themes paint (nearly) as foreground get id 0 +// and no span at all — punctuation and friends are the bulk of all spans. +func (h *highlighter) classFor(dark, light *Theme, name string) int16 { + if nearFg(dark, name) && nearFg(light, name) { + return 0 + } + key := resolveKey(dark, name) + if key == "" { + key = resolveKey(light, name) + } + cls := strings.ReplaceAll(key, ".", "-") + for i, n := range h.classNames { + if n == cls { + return int16(i) + } + } + h.classNames = append(h.classNames, cls) + return int16(len(h.classNames) - 1) +} + type paint struct { start, end uint pattern uint cls int16 } -// classify is safe for concurrent use: the shared query is immutable, and -// parser and cursor are per-call. +// 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. +// +// 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 { classes := make([]int16, len(src)) - if !strings.HasSuffix(path, ".rs") { + l := h.byExt[strings.ToLower(filepath.Ext(path))] + if l == nil { return classes } parser := sitter.NewParser() defer parser.Close() - parser.SetLanguage(h.lang) + parser.SetLanguage(l.lang) tree := parser.Parse(src, nil) defer tree.Close() qc := sitter.NewQueryCursor() defer qc.Close() - matches := qc.Matches(h.query, tree.RootNode(), src) + matches := qc.Matches(l.query, tree.RootNode(), src) var paints []paint for m := matches.Next(); m != nil; m = matches.Next() { for _, c := range m.Captures { - if cls := h.captureCls[c.Index]; cls != 0 { + if cls := l.captureCls[c.Index]; cls != 0 { paints = append(paints, paint{c.Node.StartByte(), c.Node.EndByte(), m.PatternIndex, cls}) } } } - // later patterns in the query paint over earlier ones + // later patterns in the query paint over earlier ones; two captures of one + // pattern must not overlap, since this sort leaves their order arbitrary sort.Slice(paints, func(i, j int) bool { return paints[i].pattern < paints[j].pattern }) for _, p := range paints { for i := p.start; i < p.end && int(i) < len(classes); i++ { diff --git a/vrsite/serve.go b/vrsite/serve.go index c73630c..c6e1ef1 100644 --- a/vrsite/serve.go +++ b/vrsite/serve.go @@ -1,10 +1,24 @@ // serve.go — vrsite as a live web application. // -// The log is the only state: it is re-read whenever it grows, notes written -// from the browser are appended to it, and the repository is only ever read -// (see jj.go). Pages are rendered per request from the same templates the -// static export uses; htmx swaps the parts that change on their own — -// filtered note lists, the activity of agents currently reading, jj queries. +// The server owns nothing. Three rules keep it that way, and app_test.go holds +// each of them: +// +// - The log is the only thing written. Notes typed in the browser are +// appended to it exactly as vr appends them; the repository is only ever +// read (see jj.go), and no file of the app's own is ever created. +// - Memory is a cache, never a source. The model, the per-change +// projections, file lists and rendered pages are all derived from the log +// and the repo, so a restart changes nothing anyone can see. +// - What is cached is keyed by what it was derived from: the log's +// generation, and the commit id — not the change id, which is a pointer +// that moves whenever the change is edited. +// +// What a reader chose lives in the URL (which change, how wide the view) or in +// their own browser (theme, focus, which folders are open). None of it is here. +// +// Pages are rendered per request from the same templates the static export +// uses; htmx swaps the parts that change on their own — filtered note lists, +// the activity of agents currently reading, jj queries. package main import ( @@ -33,6 +47,7 @@ type server struct { tpl *template.Template hl *highlighter theme, light *Theme + tx *transcripts mu sync.Mutex site *Site @@ -41,14 +56,19 @@ type server struct { state logState gen int def string // resolved change the log points at - trees map[string][]*File // change -> file list - rendered map[string]*File // gen|change|path -> rendered file + models map[string]*Site // scope key -> the log projected onto it + trees map[string][]*File // scope key|change -> file list + rendered map[string]*File // gen|scope|change|path -> rendered file } +// 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 { sv := &server{logPath: logPath, repo: repo, title: title, user: user, session: "web:" + user, theme: theme, light: light, - tpl: newTemplates(), trees: map[string][]*File{}, rendered: map[string]*File{}} + tpl: newTemplates(), models: map[string]*Site{}, trees: map[string][]*File{}, + rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())} sv.hl = newHighlighter(theme, light) sv.reload(true) return sv @@ -63,8 +83,7 @@ func (sv *server) reload(force bool) { } if err != nil { if sv.site == nil { - sv.site = buildModel(sv.title, nil) - sv.site.Theme, sv.site.Light = sv.theme, sv.light + sv.site = sv.dress(buildModel(sv.title, nil, "")) } return } @@ -84,61 +103,108 @@ func (sv *server) reload(force bool) { sv.entries = append(sv.entries, parseLog(buf[:i+1])...) } sv.state = st - sv.site = buildModel(sv.title, sv.entries) - sv.site.Theme, sv.site.Light, sv.site.Repo = sv.theme, sv.light, sv.repo + sv.site = sv.dress(buildModel(sv.title, sv.entries, "")) sv.gen++ sv.def = "" + sv.models = map[string]*Site{} sv.trees = map[string][]*File{} sv.rendered = map[string]*File{} } -// snap returns the current model, its generation, and the change the log -// points at (falling back to the working copy when that change is unknown to -// this repo). +func (sv *server) dress(s *Site) *Site { + s.Theme, s.Light, s.Repo = sv.theme, sv.light, sv.repo + return s +} + +// snap returns the whole-log model, its generation, and the change the log +// points at: the most-logged change this repo actually has, since a log can +// carry entries from several repos, falling back to the working copy. func (sv *server) snap() (*Site, int, string) { sv.mu.Lock() defer sv.mu.Unlock() sv.reload(false) if sv.def == "" { - def := sv.site.Change - if def == "" { - def = "@" + for i, c := range sv.site.Ranked { + if i == 5 { + break // a log with many foreign changes is not worth probing + } + if id, err := resolveChange(sv.repo, c); err == nil { + sv.def = id + break + } } - if id, err := resolveChange(sv.repo, def); err == nil { - sv.def = id - } else if id, err := resolveChange(sv.repo, "@"); err == nil { - sv.def = id - } else { - sv.def = def + if sv.def == "" { + if id, err := resolveChange(sv.repo, "@"); err == nil { + sv.def = id + } else { + sv.def = sv.site.Change + } } - sv.site.All = sv.filesLocked(sv.site, sv.def) } return sv.site, sv.gen, sv.def } -func (sv *server) filesLocked(site *Site, change string) []*File { - if f, ok := sv.trees[change]; ok { +// modelFor returns the log projected onto one change, or the whole log when +// change is "". Built once per generation, since the projection is pure. +func (sv *server) modelFor(change string) *Site { + key := change + if key == "" { + key = scopeAll + } + sv.mu.Lock() + defer sv.mu.Unlock() + if s, ok := sv.models[key]; ok { + return s + } + s := sv.site + if change != "" { + s = sv.dress(buildModel(sv.title, sv.entries, change)) + } + sv.models[key] = s + return s +} + +func scopeKey(site *Site) string { + if site.Only == "" { + return scopeAll + } + return site.Only +} + +func (sv *server) filesLocked(site *Site, commit, change string) []*File { + key := scopeKey(site) + "|" + commit + if f, ok := sv.trees[key]; ok { return f } - paths, err := fileList(sv.repo, change) + paths, err := fileList(sv.repo, commit) if err != nil { return nil } f := site.filesAt(paths, change) - sv.trees[change] = f + sv.trees[key] = f return f } -func (sv *server) files(site *Site, change string) []*File { +func (sv *server) files(site *Site, commit, change string) []*File { sv.mu.Lock() defer sv.mu.Unlock() - return sv.filesLocked(site, change) + return sv.filesLocked(site, commit, change) +} + +// forgetDefault drops the chosen change so the next request picks again — +// the repo moved under us, which is the repo's business, not ours to remember. +func (sv *server) forgetDefault() { + sv.mu.Lock() + sv.def = "" + sv.mu.Unlock() } -// fileAt renders one file at a change: model coverage and notes, source from -// jj, highlighted. Results are cached per log generation. -func (sv *server) fileAt(site *Site, gen int, change, path string) *File { - key := fmt.Sprintf("%d|%s|%s", gen, change, path) +// fileAt renders one file: model coverage and notes, source from jj, +// highlighted. commit is the content it is read at and what the cache is keyed +// on — change only names it on the page — so an edit under the same change id +// is a different key, never a stale hit. +func (sv *server) fileAt(site *Site, gen int, commit, change, path string) *File { + key := fmt.Sprintf("%d|%s|%s|%s", gen, scopeKey(site), commit, path) sv.mu.Lock() if f, ok := sv.rendered[key]; ok { sv.mu.Unlock() @@ -155,7 +221,7 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File { if f.Change == "" { f.Change = change } - src, err := fileShow(sv.repo, change, path) + src, err := fileShow(sv.repo, commit, path) switch { case err != nil: f.Stub = "not present at this change" @@ -178,20 +244,56 @@ func (sv *server) fileAt(site *Site, gen int, change, path string) *File { // ── request plumbing ────────────────────────────────────────────────────── -// newPage resolves ?rev= and fills in everything the layout needs. +// newPage answers the two questions every page starts from: which change am I +// looking at, and am I seeing only what was recorded there. ?rev= picks the +// change (default: the one the log points at); ?scope=all widens the view to +// the whole log, and a change nothing was ever recorded at widens by itself, +// so the site never looks mysteriously empty. func (sv *server) newPage(r *http.Request, kind, title string) (*page, *Site, error) { - site, _, def := sv.snap() - p := &page{Site: site, Kind: kind, Title: title, Root: "/", Live: true, - User: sv.user, ChangeID: def, Facets: site.Facets()} - rev := strings.TrimSpace(r.URL.Query().Get("rev")) - if rev != "" && rev != def { - id, err := resolveChange(sv.repo, rev) - if err != nil { - return p, site, err + full, _, def := sv.snap() + q := r.URL.Query() + p := &page{Site: full, Kind: kind, Title: title, Root: "/", Live: true, + User: sv.user, ChangeID: def, Default: def, Path: r.URL.Path, Scope: "change"} + rev := strings.TrimSpace(q.Get("rev")) + target := rev + if target == "" { + target = def + } + row, err := changeBrief(sv.repo, target) + if err != nil { + if rev == "" { + sv.forgetDefault() // the log's change left the repo; pick again } - p.Rev, p.ChangeID = rev, id + p.Facets = full.Facets() + return p, full, err + } + p.ChangeID, p.Commit, p.ChangeInfo = row.ID, row.Commit, &row + if rev != "" && row.ID != def { + p.Rev = rev } - return p, site, nil + if q.Get("scope") == "all" { + p.Scope = "all" + } + return p, sv.rescope(p), nil +} + +// rescope picks the model for the change the page settled on. Handlers that +// discover their change from the path (a change page) call it again. +func (sv *server) rescope(p *page) *Site { + full, _, _ := sv.snap() + site := full + p.ScopeEmpty = false + if p.Scope == "change" { + if full.Votes[p.ChangeID] > 0 { + site = sv.modelFor(p.ChangeID) + } else { + p.ScopeEmpty = true // asked for this change, nothing was recorded here + } + } + p.Site, p.Facets = site, site.Facets() + p.Here = full.NotesAt[p.ChangeID] + p.Elsewhere = full.NotesTotal - p.Here + return site } func (sv *server) render(w http.ResponseWriter, p *page) { @@ -233,6 +335,7 @@ func (sv *server) handler() http.Handler { mux.HandleFunc("GET /notes/list", sv.notesList) mux.HandleFunc("POST /notes", sv.addNote) mux.HandleFunc("GET /activity", sv.activity) + mux.HandleFunc("GET /session/{id}", sv.sessionPage) mux.HandleFunc("GET /jj", sv.jjPage) mux.HandleFunc("GET /jj/log", sv.jjLog) mux.HandleFunc("GET /jj/op", sv.jjOp) @@ -254,6 +357,7 @@ func (sv *server) index(w http.ResponseWriter, r *http.Request) { if len(p.Notes) > 24 { p.Notes = p.Notes[:24] } + p.Files = sv.files(site, p.Commit, p.ChangeID) sv.render(w, p) } @@ -264,7 +368,7 @@ func (sv *server) filesPage(w http.ResponseWriter, r *http.Request) { sv.render(w, p) return } - p.Tree = template.HTML(renderTree(sv.files(site, p.ChangeID), p.codeLink())) + p.Tree = template.HTML(renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink())) sv.render(w, p) } @@ -275,7 +379,7 @@ func (sv *server) treeFrag(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderTree(sv.files(site, p.ChangeID), p.codeLink())) + fmt.Fprint(w, renderTree(sv.files(site, p.Commit, p.ChangeID), p.codeLink())) } func (sv *server) codePage(w http.ResponseWriter, r *http.Request) { @@ -288,7 +392,7 @@ func (sv *server) codePage(w http.ResponseWriter, r *http.Request) { } _, gen, _ := sv.snap() p.Current = path - p.File = sv.fileAt(site, gen, p.ChangeID, path) + p.File = sv.fileAt(site, gen, p.Commit, p.ChangeID, path) sv.render(w, p) } @@ -338,15 +442,25 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { http.Error(w, "a note needs text", http.StatusBadRequest) return } + // 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() rev := strings.TrimSpace(r.FormValue("rev")) - if rev == "" { - rev = "@" + target := strings.TrimSpace(r.FormValue("change")) + for _, fallback := range []string{rev, def, "@"} { + if target != "" { + break + } + target = fallback } - change, err := resolveChange(sv.repo, rev) + change, err := resolveChange(sv.repo, target) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + if rev == "" { + rev = target + } start := atoiDefault(r.FormValue("start"), 0) end := atoiDefault(r.FormValue("end"), 0) if end < start { @@ -371,6 +485,12 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { http.Error(w, "write log: "+err.Error(), http.StatusInternalServerError) return } + // answer in the same view the writer is looking at, or the note count + // coming back would be the whole log's while their page shows one change + scope := r.FormValue("scope") + if scope != "all" { + site = sv.modelFor(change) + } if !htmxReq(r) { back := r.FormValue("back") @@ -406,6 +526,24 @@ func (sv *server) activity(w http.ResponseWriter, r *http.Request) { sv.frag(w, "activity", p) } +// sessionPage shows one session's conversation, with ?note=N anchoring the +// moment a note was written. It renders from the log alone when the harness +// transcript cannot be found — the reading timeline is still worth having. +func (sv *server) sessionPage(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := safeSession(id); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + p, site, err := sv.newPage(r, "session", "session "+short(id)+" — "+sv.title) + if err != nil { + p.Err = err.Error() + } + q := r.URL.Query() + p.Conv = sv.conversation(site, id, atoiDefault(q.Get("note"), 0), q.Get("all") != "") + sv.render(w, p) +} + // ── jj ──────────────────────────────────────────────────────────────────── const defaultLimit = 50 @@ -479,8 +617,11 @@ func (sv *server) changePage(w http.ResponseWriter, r *http.Request) { return } p.Change = c - p.Rev, p.ChangeID = c.Row.ID, c.Row.ID + p.Rev, p.ChangeID, p.Commit = c.Row.ID, c.Row.ID, c.Row.Commit + p.ChangeInfo = &c.Row p.Title = c.Row.Short + " — " + sv.title + // the page named a change the query string did not, so the view follows it + site = sv.rescope(p) p.Notes = site.FilterNotes(NoteFilter{Change: c.Row.ID}) sv.render(w, p) } @@ -510,12 +651,14 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) { w.Write(b) } -func serve(addr, logPath, repo, title, user string, theme, light *Theme) { +func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme) { sv := newServer(logPath, repo, title, user, theme, light) + 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: 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 { fatal("%v", err) } diff --git a/vrsite/static.go b/vrsite/static.go index e4970c6..7d31b1d 100644 --- a/vrsite/static.go +++ b/vrsite/static.go @@ -33,20 +33,32 @@ func run(dir string, name string, args ...string) []byte { func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) { start := time.Now() - site := buildModel(title, readLog(logPath)) - site.Theme, site.Light, site.Repo = theme, light, repoAbs - if site.Change == "" { + entries := readLog(logPath) + ranked := buildModel(title, entries, "").Ranked + if len(ranked) == 0 { fatal("log has no jj change ids") } - - // every file in the repo at the site change; content comes from one - // git archive of the commit instead of a jj spawn per file - list, err := jjRun(repoAbs, "file", "list", "-r", site.Change) - if err != nil { - fmt.Fprintln(os.Stderr, err) - fatal("change %s is not in %s — logs are per repository; point -repo at the one the trace was recorded in", short(site.Change), repoAbs) + // the most-logged change this repo actually has: a log can span repos, and + // the busiest change in it may belong to another one + var change string + var list []byte + for _, c := range ranked { + out, err := jjRun(repoAbs, "file", "list", "-r", c) + if err == nil { + change, list = c, out + break + } } - site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), site.Change) + if change == "" { + fatal("none of the log's %d changes are in %s — logs are per repository; point -repo at the one the trace was recorded in", + len(ranked), repoAbs) + } + // an export is a picture of one change, so it holds only what was + // recorded there; the notes page says how much of the log that leaves out + site := buildModel(title, entries, change) + site.Change = change + site.Theme, site.Light, site.Repo = theme, light, repoAbs + site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), change) commit := strings.TrimSpace(string(mustJJ(repoAbs, "log", "-r", site.Change, "--no-graph", "-T", "commit_id"))) tmp, err := os.MkdirTemp("", "vrsite-") @@ -140,8 +152,22 @@ func writeSite(site *Site, out, srcDir string) { } facets := site.Facets() + var info *Row + if rows, err := LogRows(site.Repo, site.Change, "", 1); err == nil { + for _, row := range rows { + if !row.Blank() { + r := row + info = &r + break + } + } + } render := func(path string, p *page) { - p.Site, p.Facets, p.ChangeID = site, facets, site.Change + p.Site, p.Facets = site, facets + p.ChangeID, p.Default, p.ChangeInfo = site.Change, site.Change, info + p.Scope, p.Here = "change", site.NotesAt[site.Change] + p.Elsewhere = site.NotesTotal - p.Here + p.Files = site.All f := mustCreate(path) if err := tpl.Execute(f, p); err != nil { fatal("render %s: %v", path, err) diff --git a/vrsite/templates/frag-activity.tmpl b/vrsite/templates/frag-activity.tmpl index 8c15de7..071c556 100644 --- a/vrsite/templates/frag-activity.tmpl +++ b/vrsite/templates/frag-activity.tmpl @@ -4,7 +4,8 @@ <div class="actlist"> {{range .Sessions}} <div class="act{{if .Live}} on{{end}}" title="{{.Agent}} · {{.Model}} · {{.Dir}}"> - <span class="chip am{{.Bit}}">{{.Short}}</span> + {{if and $.Live .ID}}<a class="chip am{{.Bit}}" href="{{$.Href "session" .ID}}">{{.Short}}</a> + {{else}}<span class="chip am{{.Bit}}">{{.Short}}</span>{{end}} <span class="what">{{.LastOp}}</span> <span class="when">{{.Ago}}</span> </div> @@ -19,7 +20,8 @@ <tr class="{{if .Live}}on{{end}}"> <td><span class="chip am{{.Bit}}">{{.Short}}</span>{{if .Live}}<span class="pulse" title="active">●</span>{{end}}</td> <td>{{.Model}}{{if .Reason}}<span class="meta">/{{.Reason}}</span>{{end}}</td> - <td><code title="{{.ID}}">{{short .ID}}</code></td> + <td>{{if and $.Live .ID}}<a href="{{$.Href "session" .ID}}" title="{{.ID}}"><code>{{short .ID}}</code></a> + {{else}}<code title="{{.ID}}">{{short .ID}}</code>{{end}}</td> <td class="what">{{.LastOp}}</td> <td>{{.Ago}}</td> <td class="meta">{{.Reads}}/{{.Greps}}/{{.Notes}}</td> diff --git a/vrsite/templates/frag-change.tmpl b/vrsite/templates/frag-change.tmpl new file mode 100644 index 0000000..b33d746 --- /dev/null +++ b/vrsite/templates/frag-change.tmpl @@ -0,0 +1,41 @@ +{{/* The change everything on screen is read at, and how wide the view is. + Every page carries it, because every read, note and file listing below + is relative to it. */}} +{{define "changebar"}} +<section class="changebar{{if .Rev}} away{{end}}" aria-label="current change"> + <div class="cid"> + <span class="lbl">change</span> + {{if .Live}}<a href="{{.Href "change" .ChangeID}}" title="{{.ChangeID}}"><code>{{short .ChangeID}}</code></a> + {{else}}<code title="{{.ChangeID}}">{{short .ChangeID}}</code>{{end}} + {{with .ChangeInfo}}{{if .Flags}}<span class="flags">{{.Flags}}</span>{{end}}{{end}} + </div> + {{with .ChangeInfo}} + <p class="desc" title="{{.Desc}}">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</p> + {{end}} + + <p class="counts"> + {{if eq .Scope "all"}} + <b>{{.Site.NotesTotal}}</b> notes across every change{{if .Here}} · {{.Here}} here{{end}} + {{else if .ScopeEmpty}} + nothing recorded at this change — showing all <b>{{.Site.NotesTotal}}</b> + {{else}} + <b>{{.Here}}</b> notes here{{if .Elsewhere}} · <span class="away">{{.Elsewhere}} elsewhere</span>{{end}} + {{end}} + </p> + + {{if .Live}} + <div class="scope" role="group" aria-label="what this view covers"> + <a href="{{.ScopeHref "change"}}"{{if eq .Scope "change"}} class="on" aria-current="true"{{end}} + title="only what was recorded at this change">this change</a> + <a href="{{.ScopeHref "all"}}"{{if eq .Scope "all"}} class="on" aria-current="true"{{end}} + title="every change in the log">all</a> + </div> + <form class="revjump" method="get" action="{{.JumpPath}}"> + <input type="search" name="rev" value="{{.Rev}}" placeholder="@, id, revset…" + aria-label="read at another change" autocomplete="off" spellcheck="false"> + {{if eq .Scope "all"}}<input type="hidden" name="scope" value="all">{{end}} + </form> + {{if .Rev}}<p class="back"><a href="{{.BackHref}}">← back to <code>{{short .Default}}</code>, where the log points</a></p>{{end}} + {{end}} +</section> +{{end}} diff --git a/vrsite/templates/frag-note.tmpl b/vrsite/templates/frag-note.tmpl index 56f7a8f..0c3182c 100644 --- a/vrsite/templates/frag-note.tmpl +++ b/vrsite/templates/frag-note.tmpl @@ -1,7 +1,8 @@ -{{define "note"}}{{$n := .N}}{{$p := .P}} -<article class="note am{{$n.Agent.Bit}}" id="note-{{$n.ID}}" +{{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-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}}"> <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}} @@ -12,7 +13,12 @@ {{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}} - {{if $n.Change}}{{if $p.Live}}<a href="{{$p.Href "change" $n.Change}}"><code>{{short $n.Change}}</code></a>{{else}}<code>{{short $n.Change}}</code>{{end}}{{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> + {{else}}<code class="atchg{{if $away}} away{{end}}">{{short $n.Change}}</code>{{end}} + {{end}} </footer> </article> {{end}} @@ -30,6 +36,8 @@ hx-on::after-request="if(event.detail.successful){this.reset();{{if eq .Kind "notes"}}htmx.trigger('#notefilters','change');{{end}}}" method="post" action="{{.Href "notes"}}"> <input type="hidden" name="rev" value="{{.Rev}}"> + <input type="hidden" name="change" value="{{.ChangeID}}"> + <input type="hidden" name="scope" value="{{.Scope}}"> <input type="hidden" name="back" value="{{if .File}}{{.Href "code" .File.Path}}{{else}}{{.Href "notes"}}{{end}}"> <div class="row"> {{if .File}} @@ -44,7 +52,7 @@ </div> <textarea name="text" rows="3" placeholder="comment… (⌘/ctrl+enter to post)" required></textarea> <div class="row end"> - <span class="meta">as <b>{{.User}}</b> → the log</span> + <span class="meta">as <b>{{.User}}</b> at <code title="{{.ChangeID}}">{{short .ChangeID}}</code> → the log</span> <button type="submit">add note</button> </div> </form> diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl index c3fe250..9bc9ddd 100644 --- a/vrsite/templates/layout.tmpl +++ b/vrsite/templates/layout.tmpl @@ -23,9 +23,7 @@ <button type="button" class="mode" data-mode="notes" title="notes and comments (t)">notes</button> </div> </header> - {{if and .Live .Rev}} - <p class="meta atrev">browsing <a href="{{.Href "change" .ChangeID}}"><code>{{short .ChangeID}}</code></a> · <a href="/">back to @</a></p> - {{end}} + {{template "changebar" .}} {{if .Live}} <div id="activity" class="activity" hx-get="/activity?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div> {{end}} @@ -52,6 +50,7 @@ {{else if eq .Kind "files"}}{{template "page-files" .}} {{else if eq .Kind "jj"}}{{template "page-jj" .}} {{else if eq .Kind "change"}}{{template "page-change" .}} +{{else if eq .Kind "session"}}{{template "page-session" .}} {{else}}{{template "page-index" .}}{{end}} </main> diff --git a/vrsite/templates/page-files.tmpl b/vrsite/templates/page-files.tmpl index 53757ea..3f158fe 100644 --- a/vrsite/templates/page-files.tmpl +++ b/vrsite/templates/page-files.tmpl @@ -2,8 +2,8 @@ <header class="filehead"> <h1>Files</h1> <p class="meta"> - everything at change <code>{{short .ChangeID}}</code> · - <span class="trace-only">traced files carry a coverage bar</span> + everything at change <code title="{{.ChangeID}}">{{short .ChangeID}}</code> · + <span class="trace-only">traced files carry a coverage bar{{if eq .Scope "change"}}, from reads at this change{{end}}</span> <span class="notes-only">◆ marks files with notes</span> </p> </header> diff --git a/vrsite/templates/page-index.tmpl b/vrsite/templates/page-index.tmpl index 2c115cc..bd377ac 100644 --- a/vrsite/templates/page-index.tmpl +++ b/vrsite/templates/page-index.tmpl @@ -2,8 +2,10 @@ <header class="filehead"> <h1>{{.Site.Title}}</h1> <p class="meta"> - {{len .Site.All}} files, {{len .Site.Order}} traced · {{len .Site.Notes}} notes · - change <code>{{short .Site.Change}}</code> · theme {{.Site.Theme.Name}} + {{len .Files}} files at <code title="{{.ChangeID}}">{{short .ChangeID}}</code> · + {{len .Site.Order}} traced, {{len .Site.Notes}} notes + {{if or (eq .Scope "all") .ScopeEmpty}}<span class="away">across every change</span>{{else}}here{{end}} · + theme {{.Site.Theme.Name}} </p> </header> @@ -23,7 +25,9 @@ <tbody> {{range .Site.Agents}} <tr><td><span class="chip am{{.Bit}}">{{.Short}}</span></td> - <td>{{.Model}}</td><td>{{.Reason}}</td><td><code>{{short .Session}}</code></td> + <td>{{.Model}}</td><td>{{.Reason}}</td> + <td>{{if and $.Live .Session}}<a href="{{$.Href "session" .Session}}" title="{{.Session}}"><code>{{short .Session}}</code></a> + {{else}}<code title="{{.Session}}">{{short .Session}}</code>{{end}}</td> <td>{{.Reads}}</td><td>{{.Greps}}</td><td>{{.Notes}}</td></tr> {{end}} </tbody> diff --git a/vrsite/templates/page-notes.tmpl b/vrsite/templates/page-notes.tmpl index 974dc64..242c137 100644 --- a/vrsite/templates/page-notes.tmpl +++ b/vrsite/templates/page-notes.tmpl @@ -7,7 +7,16 @@ <header class="filehead"> <h1>Notes</h1> <p class="meta"> - {{len .Site.Notes}} in the log{{if .Live}} · anything you add here is appended to the log — the repo is never touched{{end}} + {{if eq .Scope "all"}} + all {{.Site.NotesTotal}} in the log, every change + {{else if .ScopeEmpty}} + all {{.Site.NotesTotal}} in the log — nothing was recorded at <code>{{short .ChangeID}}</code> + {{else}} + {{len .Site.Notes}} written at <code title="{{.ChangeID}}">{{short .ChangeID}}</code>{{if .Elsewhere}} · + {{if .Live}}<a href="{{.ScopeHref "all"}}">{{.Elsewhere}} more at other changes</a> + {{else}}{{.Elsewhere}} more at other changes are not in this export{{end}}{{end}} + {{end}} + {{if .Live}} · anything you add here is appended to the log at this change — the repo is never touched{{end}} </p> </header> diff --git a/vrsite/templates/page-session.tmpl b/vrsite/templates/page-session.tmpl new file mode 100644 index 0000000..9a35e8d --- /dev/null +++ b/vrsite/templates/page-session.tmpl @@ -0,0 +1,70 @@ +{{/* One session's conversation, with its vr log woven in by time. */}} +{{define "page-session"}} +{{$c := .Conv}} +<header class="filehead"> + <h1> + {{if $c.Agent}}<span class="chip am{{$c.Bit}}">{{$c.Agent}}</span> {{end}} + <code title="{{$c.Session}}">{{short $c.Session}}</code> + </h1> + <p class="meta"> + {{if $c.Model}}{{$c.Model}} · {{end}}{{$c.Reads}} reads · {{$c.Greps}} greps · {{$c.Notes}} notes + {{if $c.Dir}} · <span title="{{$c.Dir}}">{{base $c.Dir}}</span>{{end}} + {{if $c.Harness}} · {{$c.Harness}}{{end}} + </p> + {{if $c.Path}} + <p class="meta"><code>{{$c.Path}}</code></p> + {{else}} + <p class="meta hint">no transcript found for this session — searched + {{if $c.Roots}}{{range $i, $r := $c.Roots}}{{if $i}}, {{end}}<code>{{$r}}</code>{{end}}{{else}}nowhere: <code>-transcripts</code> is empty{{end}} · + showing the vr log alone</p> + {{end}} + {{if $c.Err}}<p class="err">{{$c.Err}}</p>{{end}} +</header> + +<section class="convpage"> + {{if $c.Windowed}} + <p class="meta"> + moments {{$c.From}}–{{$c.To}} of {{$c.Total}}{{if $c.Anchor}}, around the note{{end}} · + {{if $c.Incomplete}}the rest is beyond what one page renders{{else}}<a href="{{$c.AllHref}}">show the whole conversation</a>{{end}} + </p> + {{end}} + + <div class="conv" data-anchor="{{$c.Anchor}}"> + {{range $c.Turns}} + {{if .Note}} + <div class="turn t-note{{if .Here}} here{{end}}"> + <span class="role">note</span><time>{{.Clock}}</time> + {{if .Note.File}} + <div class="notewrap" data-href="{{$.NoteHref .Note}}" tabindex="0" role="link" + title="open this note on the code it is about">{{template "note" (dict "N" .Note "P" $)}}</div> + {{else}} + <div class="notewrap">{{template "note" (dict "N" .Note "P" $)}}</div> + {{end}} + </div> + {{else if eq .Role "vr"}} + <div class="turn t-vr"> + <span class="role">vr</span><time>{{.Clock}}</time> + {{if .File}}<a class="what" href="{{$.Href "code" .File}}">{{.Text}}</a> + {{else}}<span class="what">{{.Text}}</span>{{end}} + </div> + {{else if eq .Role "tool"}} + <div class="turn t-tool"> + <span class="role">{{.Tool}}</span><time>{{.Clock}}</time> + <code class="what">{{.Detail}}</code> + </div> + {{else if eq .Role "result"}} + <div class="turn t-result{{if eq .Kind "error"}} bad{{end}}"> + <span class="role">→{{if .Tool}} {{.Tool}}{{end}}</span><time></time> + <code class="what">{{.Detail}}</code> + </div> + {{else}} + <div class="turn t-say t-{{.Role}}"> + <span class="role">{{.Role}}</span><time>{{.Clock}}</time> + <p>{{.Text}}</p> + </div> + {{end}} + {{end}} + {{if not $c.Turns}}<p class="meta hint">nothing recorded for this session</p>{{end}} + </div> +</section> +{{end}} diff --git a/vrsite/transcript.go b/vrsite/transcript.go new file mode 100644 index 0000000..713fe36 --- /dev/null +++ b/vrsite/transcript.go @@ -0,0 +1,602 @@ +// transcript.go — the conversation a session's reading happened inside. +// +// The vr log says a session read a file at 10:04; the harness that ran that +// session kept the whole conversation on disk, in its own format. Here we find +// that file by session id and flatten both known formats into Turns, so a note +// can be opened at the moment it was written. +// +// Two rules keep this cheap and safe. Tool calls and their output are reduced +// to a line each — transcripts reach megabytes and none of that belongs in a +// page — and nothing outside a configured root is ever opened, with session +// ids checked before they touch the filesystem. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +const ( + maxTranscript = 32 << 20 // refuse to parse more than this in one file + transcriptCacheMax = 8 // parsed transcripts are large; keep few + briefLine = 160 // one line of a tool call or its output + maxText = 6000 // one message + convWindow = 80 // turns rendered around the anchored moment + convMax = 1500 // turns rendered by "show all" +) + +// A Turn is one moment in a session: something said, a tool called or answered, +// or — woven in from the vr log — a read, a grep, a note. +type Turn struct { + Role string // user | assistant | thinking | tool | result | vr + Kind string // qualifies Role: the vr op, or "error" on a failed tool + When time.Time + Text string // what was said + Tool string // tool name, on tool and result turns + Detail string // one compact line: the call's arguments, or its output + File string // vr turns: the file the op touched + Note *Note // vr note turns: the note itself, rendered as a card + Here bool // the anchored moment +} + +func (t Turn) Clock() string { + if t.When.IsZero() { + return "" + } + return t.When.Local().Format("15:04:05") +} + +// A Transcript is one harness conversation, already normalized. +type Transcript struct { + Path string + Harness string // claude-code | codex + Turns []Turn + Err string +} + +// transcripts finds and parses harness transcripts, remembering what it has +// already parsed. Roots are the only places it will look. +type transcripts struct { + roots []string + + mu sync.Mutex + cache map[string]*Transcript // path|mtime|size -> parsed +} + +func newTranscripts(roots []string) *transcripts { + tx := &transcripts{cache: map[string]*Transcript{}} + for _, r := range roots { + r = strings.TrimSpace(r) + if r == "" { + continue + } + if abs, err := filepath.Abs(r); err == nil { + r = abs + } + tx.roots = append(tx.roots, filepath.Clean(r)) + } + return tx +} + +// defaultTranscriptRoots are where the two harnesses we know about keep their +// conversations. +func defaultTranscriptRoots() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{filepath.Join(home, ".claude", "projects"), filepath.Join(home, ".codex", "sessions")} +} + +// safeSession refuses ids that could name something we did not mean to open: +// the id goes into a filesystem path, so it must be one plain component. +func safeSession(id string) error { + if id == "" { + return fmt.Errorf("no session id") + } + if len(id) > 128 { + return fmt.Errorf("session id is too long") + } + for _, r := range id { + ok := r == '-' || r == '_' || r == ':' || r == '.' || + '0' <= r && r <= '9' || 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' + if !ok { + return fmt.Errorf("session id contains %q", r) + } + } + if strings.Contains(id, "..") { + return fmt.Errorf("session id contains %q", "..") + } + return nil +} + +// mangleDir is how Claude Code names a project directory after its cwd: +// /home/u/0x4200.cafe -> -home-u-0x4200-cafe. +func mangleDir(dir string) string { + return strings.NewReplacer("/", "-", ".", "-").Replace(dir) +} + +// under states the rule the globs below already obey: a transcript we open +// lies inside a configured root. It is checked anyway, so the rule has one +// place to fail rather than living in the shape of two patterns. +func under(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// find locates a session's transcript. Claude Code writes +// <root>/<mangled cwd>/<session>.jsonl; codex writes +// <root>/YYYY/MM/DD/rollout-<time>-<thread>.jsonl and vr's session id is that +// thread id, so it is matched on the suffix. Both patterns are tried under +// every root — a root knows its own layout, we do not have to. +func (tx *transcripts) find(session, dir string) string { + if safeSession(session) != nil { + return "" + } + for _, root := range tx.roots { + pats := []string{ + filepath.Join(root, "*", session+".jsonl"), + filepath.Join(root, "*", "*", "*", "rollout-*-"+session+".jsonl"), + } + // the cwd we logged names the directory directly, when it still exists + if dir != "" { + pats = append([]string{filepath.Join(root, mangleDir(dir), session+".jsonl")}, pats...) + } + for _, pat := range pats { + hits, _ := filepath.Glob(pat) + for _, hit := range hits { + if under(root, hit) { + return hit + } + } + } + } + return "" +} + +// load returns the parsed transcript for a session, or nil when there is none. +// Parses are cached by path, mtime and size, so a 5 MB file is read once. +func (tx *transcripts) load(session, dir string) *Transcript { + path := tx.find(session, dir) + if path == "" { + return nil + } + st, err := os.Stat(path) + if err != nil { + return nil + } + key := fmt.Sprintf("%s|%d|%d", path, st.ModTime().UnixNano(), st.Size()) + + tx.mu.Lock() + t, ok := tx.cache[key] + tx.mu.Unlock() + if ok { + return t + } + + t = readTranscript(path, st.Size()) + tx.mu.Lock() + if len(tx.cache) >= transcriptCacheMax { + tx.cache = map[string]*Transcript{} + } + tx.cache[key] = t + tx.mu.Unlock() + return t +} + +// ── parsing ─────────────────────────────────────────────────────────────── + +// One line of either format. Codex wraps every record in "payload"; Claude +// Code puts the model turn in "message". Sniffing per line keeps the two +// parsers independent of any file header. +type rawRecord struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Payload json.RawMessage `json:"payload"` + Message json.RawMessage `json:"message"` +} + +type parser struct { + t *Transcript + tools map[string]string // tool call id -> name, so results keep their name + last time.Time // records without a stamp inherit the previous one +} + +func readTranscript(path string, size int64) *Transcript { + t := &Transcript{Path: path} + if size > maxTranscript { + t.Err = fmt.Sprintf("transcript is %d MB — too large to parse", size>>20) + return t + } + b, err := os.ReadFile(path) + if err != nil { + t.Err = err.Error() + return t + } + p := &parser{t: t, tools: map[string]string{}} + for _, line := range strings.Split(string(b), "\n") { + if line == "" { + continue + } + var rec rawRecord + if json.Unmarshal([]byte(line), &rec) != nil { + continue + } + at := parseTime(rec.Timestamp) + if at.IsZero() { + at = p.last + } else { + p.last = at + } + switch { + case len(rec.Payload) > 0: + t.Harness = "codex" + p.codex(rec, at) + case len(rec.Message) > 0: + t.Harness = "claude-code" + p.claude(rec, at) + } + } + return t +} + +// add drops turns that would render as nothing — empty thinking blocks, +// tool results whose output was pure whitespace. +func (p *parser) add(t Turn) { + if t.Text == "" && t.Detail == "" && t.Tool == "" { + return + } + t.Text = clip(t.Text, maxText) + p.t.Turns = append(p.t.Turns, t) +} + +// ── claude code ─────────────────────────────────────────────────────────── + +type claudeMsg struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` // a string, or blocks +} + +type claudeBlock struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + ToolUseID string `json:"tool_use_id"` + Content json.RawMessage `json:"content"` // tool_result: a string, or blocks + IsError bool `json:"is_error"` +} + +// claude turns records of type user and assistant into Turns; the rest of the +// file (modes, titles, file history, attachments) is harness bookkeeping. +func (p *parser) claude(rec rawRecord, at time.Time) { + if rec.Type != "user" && rec.Type != "assistant" { + return + } + var m claudeMsg + if json.Unmarshal(rec.Message, &m) != nil { + return + } + var text string + if json.Unmarshal(m.Content, &text) == nil { // plain prompts arrive as a string + p.add(Turn{Role: rec.Type, When: at, Text: text}) + return + } + var blocks []claudeBlock + if json.Unmarshal(m.Content, &blocks) != nil { + return + } + for _, b := range blocks { + switch b.Type { + case "text": + p.add(Turn{Role: rec.Type, When: at, Text: b.Text}) + case "thinking": + p.add(Turn{Role: "thinking", When: at, Text: b.Thinking}) + case "tool_use": + p.tools[b.ID] = b.Name + p.add(Turn{Role: "tool", When: at, Tool: b.Name, Detail: callDetail(b.Input)}) + case "tool_result": + t := Turn{Role: "result", When: at, Tool: p.tools[b.ToolUseID], Detail: resultDetail(b.Content)} + if b.IsError { + t.Kind = "error" + } + p.add(t) + } + } +} + +// ── codex ───────────────────────────────────────────────────────────────── + +type codexPayload struct { + Type string `json:"type"` + Role string `json:"role"` + Name string `json:"name"` + CallID string `json:"call_id"` + Arguments json.RawMessage `json:"arguments"` // a JSON string holding JSON + Input json.RawMessage `json:"input"` + Output json.RawMessage `json:"output"` + Content []codexText `json:"content"` + Summary []codexText `json:"summary"` +} + +type codexText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// codex reads response_items — the model's own history. The event_msg stream +// beside it is the TUI's view of the same turns, so taking both would double +// everything. +func (p *parser) codex(rec rawRecord, at time.Time) { + if rec.Type != "response_item" { + return + } + var pl codexPayload + if json.Unmarshal(rec.Payload, &pl) != nil { + return + } + switch pl.Type { + case "message": + // developer messages are the harness's own instructions, and the first + // user messages are context it injects; neither was said by anyone + if pl.Role == "developer" { + return + } + text := joinText(pl.Content) + if injected(text) { + return + } + role := "assistant" + if pl.Role == "user" { + role = "user" + } + p.add(Turn{Role: role, When: at, Text: text}) + case "reasoning": + // summaries are usually encrypted and come back empty; add drops those + p.add(Turn{Role: "thinking", When: at, Text: joinText(pl.Summary)}) + case "function_call", "custom_tool_call", "web_search_call": + name := pl.Name + if name == "" { + name = strings.TrimSuffix(pl.Type, "_call") + } + p.tools[pl.CallID] = name + detail := callDetail(pl.Arguments) + if detail == "" { + detail = callDetail(pl.Input) + } + p.add(Turn{Role: "tool", When: at, Tool: name, Detail: detail}) + case "function_call_output", "custom_tool_call_output", "web_search_output": + p.add(Turn{Role: "result", When: at, Tool: p.tools[pl.CallID], Detail: resultDetail(pl.Output)}) + } +} + +func joinText(items []codexText) string { + var parts []string + for _, it := range items { + if it.Text != "" { + parts = append(parts, it.Text) + } + } + return strings.Join(parts, "\n\n") +} + +// injected recognizes the wrappers a harness puts around a user turn to carry +// its own state. +func injected(text string) bool { + for _, tag := range []string{"<environment_context>", "<user_instructions>", "<permissions instructions>"} { + if strings.HasPrefix(text, tag) { + return true + } + } + return false +} + +// ── summarizing ─────────────────────────────────────────────────────────── + +// callFields are the arguments worth a line, most telling first. Anything that +// carries file content (new_string, content) is deliberately not here. +var callFields = []string{"command", "cmd", "file_path", "path", "pattern", "query", "url", "prompt", "description"} + +// callDetail reduces a tool call's arguments to their one interesting field. +func callDetail(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { // codex passes arguments as a JSON string + raw = json.RawMessage(s) + } + var m map[string]any + if json.Unmarshal(raw, &m) != nil { + return brief(string(raw)) + } + for _, k := range callFields { + if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" { + return brief(v) + } + } + b, err := json.Marshal(m) + if err != nil { + return "" + } + return brief(string(b)) +} + +// resultDetail summarizes what a tool answered, whether that came back as a +// string, as content blocks, or as an image nobody wants inlined. +func resultDetail(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return brief(s) + } + var items []codexText + if json.Unmarshal(raw, &items) == nil { + var parts []string + for _, it := range items { + switch { + case it.Text != "": + parts = append(parts, it.Text) + case it.Type != "": + parts = append(parts, "["+it.Type+"]") + } + } + return brief(strings.Join(parts, "\n")) + } + return brief(string(raw)) +} + +// brief keeps a blob's first line and says how much was left behind. Whole +// files and command output pass through here; a page gets their shape, never +// their bytes. +func brief(s string) string { + s = strings.TrimRight(s, "\n") + if strings.TrimSpace(s) == "" { + return "" + } + first, rest, more := strings.Cut(s, "\n") + first = clip(strings.TrimSpace(first), briefLine) + if !more || strings.TrimSpace(rest) == "" { + return first + } + return fmt.Sprintf("%s … +%d lines", first, strings.Count(rest, "\n")+1) +} + +func clip(s string, n int) string { + if len(s) <= n { // bytes first: the common case never allocates + return s + } + r := []rune(s) + if len(r) <= n { + return s + } + return strings.TrimRight(string(r[:n]), " \t") + "…" +} + +// ── one session: the transcript with the vr log woven in ────────────────── + +// A Conv is what the session page shows: the conversation, the log entries +// that happened during it, and where in a long one we are looking. +type Conv struct { + Session, Agent, Model, Dir string + Bit uint8 + Harness, Path string + Roots []string + Err string + Reads, Greps, Notes int + + Turns []Turn + Total int // moments in the whole conversation + From, To int // 1-based position of Turns within it + Anchor string // element id to scroll to, "" when nothing is anchored + AllHref string // same page, unwindowed + Windowed bool + Incomplete bool // even "show all" had to stop +} + +// sessionTurns are the vr log's own moments for a session: what it read, +// grepped and noted. +func (s *Site) sessionTurns(id string) []Turn { + var out []Turn + for _, e := range s.Entries { + if e.Session != id || e.Op == "note" { + continue + } + out = append(out, Turn{Role: "vr", Kind: e.Op, When: parseTime(e.Time), + Text: describe(e), File: e.File}) + } + for _, n := range s.Notes { + if n.Session == id { + out = append(out, Turn{Role: "vr", Kind: "note", When: n.At, File: n.File, Note: n}) + } + } + return out +} + +// conversation merges a session's transcript with its log entries. Both sides +// are compared as instants — the log stamps carry an offset, transcripts are +// UTC — so ordering is the real one, and only the rendering is local. +func (sv *server) conversation(site *Site, id string, note int, all bool) *Conv { + c := &Conv{Session: id, Roots: sv.tx.roots} + if err := safeSession(id); err != nil { + c.Err = err.Error() + return c + } + for _, s := range site.Sessions(time.Now(), liveWindow, 0) { + if s.ID == id { + c.Agent, c.Model, c.Dir, c.Bit = s.Agent, s.Model, s.Dir, s.Bit + c.Reads, c.Greps, c.Notes = s.Reads, s.Greps, s.Notes + break + } + } + + logged := site.sessionTurns(id) + // the parsed transcript is shared with other requests, so the merge copies + // it rather than appending onto its slice + var turns []Turn + if t := sv.tx.load(id, c.Dir); t != nil { + c.Path, c.Harness, c.Err = t.Path, t.Harness, t.Err + turns = make([]Turn, 0, len(t.Turns)+len(logged)) + turns = append(turns, t.Turns...) + } + turns = append(turns, logged...) + sort.SliceStable(turns, func(i, j int) bool { return turns[i].When.Before(turns[j].When) }) + + at := -1 + if note > 0 { + for i := range turns { + if turns[i].Note != nil && turns[i].Note.ID == note { + at, turns[i].Here = i, true + c.Anchor = fmt.Sprintf("note-%d", note) + break + } + } + } + c.Total = len(turns) + from, window := convWindowAt(turns, at, all) + c.Turns, c.From, c.To = window, from+1, from+len(window) + c.Windowed = len(window) < len(turns) + c.Incomplete = c.Windowed && all + c.AllHref = "/session/" + escPath(id) + "?all=1" + if note > 0 { + c.AllHref += fmt.Sprintf("¬e=%d#note-%d", note, note) + } + return c +} + +// convWindowAt cuts a conversation down to what a page may render: a stretch +// around the anchored moment, or the tail of it when nothing is anchored. +// "show all" raises the bound but does not remove it. +func convWindowAt(turns []Turn, at int, all bool) (int, []Turn) { + n := convWindow + if all { + n = convMax + } + if len(turns) <= n { + return 0, turns + } + if at < 0 { + return len(turns) - n, turns[len(turns)-n:] + } + lo := at - n/2 + if lo < 0 { + lo = 0 + } + if lo+n > len(turns) { + lo = len(turns) - n + } + return lo, turns[lo : lo+n] +} diff --git a/vrsite/transcript_test.go b/vrsite/transcript_test.go new file mode 100644 index 0000000..5e83ef4 --- /dev/null +++ b/vrsite/transcript_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Both fixtures are the shapes the real files use: Claude Code writes one +// record per message with content blocks, codex wraps everything in a payload +// and keeps the model's history in response_items. +const claudeJSONL = `{"type":"user","timestamp":"2026-07-20T09:57:00.000Z","sessionId":"s1","cwd":"/repo","message":{"role":"user","content":"read lib.rs and tell me about the cache"}} +{"type":"assistant","timestamp":"2026-07-20T09:57:30.000Z","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"thinking","thinking":"start with the struct","signature":"CAIS"},{"type":"text","text":"Reading it now."},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"vr read src/lib.rs:1-30","description":"read the file"}}]}} +{"type":"user","timestamp":"2026-07-20T09:59:10.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"1\tuse std::collections::HashMap;\n2\t\n3\tstruct Cache;\n"}]}} +{"type":"mode","timestamp":"2026-07-20T09:59:11.000Z","mode":"default"} +{"type":"assistant","timestamp":"2026-07-20T09:59:40.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/repo/src/main.rs"}}]}} +{"type":"user","timestamp":"2026-07-20T09:59:45.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","is_error":true,"content":[{"type":"text","text":"no such file"}]}]}} +{"type":"assistant","timestamp":"2026-07-20T10:02:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"It is a two-level map."}]}} +` + +const codexJSONL = `{"timestamp":"2026-07-20T12:00:00.000Z","type":"session_meta","payload":{"id":"019f0000-0000-7000-8000-00000000abcd","cwd":"/repo"}} +{"timestamp":"2026-07-20T12:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"You are Codex, an agent."}]}} +{"timestamp":"2026-07-20T12:00:02.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/repo</cwd>\n</environment_context>"}]}} +{"timestamp":"2026-07-20T12:00:03.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the cache"}]}} +{"timestamp":"2026-07-20T12:00:04.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"start with lib.rs"}],"encrypted_content":"gAAAA"}} +{"timestamp":"2026-07-20T12:00:05.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"vr read src/lib.rs\",\"workdir\":\"/repo\"}"}} +{"timestamp":"2026-07-20T12:00:06.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"Chunk ID: abc123\nOutput:\n1\tstruct Cache;\n"}} +{"timestamp":"2026-07-20T12:00:07.000Z","type":"event_msg","payload":{"type":"agent_message","message":"the cache is two-level"}} +{"timestamp":"2026-07-20T12:00:08.000Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"call_2","input":"*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n"}} +{"timestamp":"2026-07-20T12:00:09.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"Exit code: 0\n"}} +{"timestamp":"2026-07-20T12:00:10.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The cache is two-level."}]}} +` + +func writeFile(t *testing.T, path, body string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func turnLine(tn Turn) string { + s := tn.Role + if tn.Tool != "" { + s += " " + tn.Tool + } + if tn.Text != "" { + s += " " + tn.Text + } + if tn.Detail != "" { + s += " " + tn.Detail + } + return s +} + +func TestParseClaudeTranscript(t *testing.T) { + path := writeFile(t, filepath.Join(t.TempDir(), "s.jsonl"), claudeJSONL) + tr := readTranscript(path, int64(len(claudeJSONL))) + if tr.Harness != "claude-code" || tr.Err != "" { + t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err) + } + want := []string{ + "user read lib.rs and tell me about the cache", + "thinking start with the struct", + "assistant Reading it now.", + "tool Bash vr read src/lib.rs:1-30", + "result Bash 1\tuse std::collections::HashMap; … +2 lines", + "tool Read /repo/src/main.rs", + "result Read no such file", + "assistant It is a two-level map.", + } + var got []string + for _, tn := range tr.Turns { + got = append(got, turnLine(tn)) + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if tr.Turns[6].Kind != "error" { + t.Error("a failed tool result is not marked") + } + if at := tr.Turns[0].When; at.Format(time.RFC3339) != "2026-07-20T09:57:00Z" { + t.Errorf("first turn at %v", at) + } +} + +func TestParseCodexTranscript(t *testing.T) { + path := writeFile(t, filepath.Join(t.TempDir(), "r.jsonl"), codexJSONL) + tr := readTranscript(path, int64(len(codexJSONL))) + if tr.Harness != "codex" || tr.Err != "" { + t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err) + } + want := []string{ + "user review the cache", + "thinking start with lib.rs", + "tool exec_command vr read src/lib.rs", + "result exec_command Chunk ID: abc123 … +2 lines", + "tool apply_patch *** Begin Patch … +4 lines", + "result apply_patch Exit code: 0", + "assistant The cache is two-level.", + } + var got []string + for _, tn := range tr.Turns { + got = append(got, turnLine(tn)) + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// Tool output is what makes these files megabytes; a page gets one line of it. +func TestToolOutputIsSummarized(t *testing.T) { + huge := strings.Repeat("x", 400) + "\n" + strings.Repeat("line\n", 50000) + line := fmt.Sprintf(`{"type":"user","timestamp":"2026-07-20T10:00:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t","content":%q}]}}`, huge) + path := writeFile(t, filepath.Join(t.TempDir(), "big.jsonl"), line+"\n") + tr := readTranscript(path, int64(len(line)+1)) + if len(tr.Turns) != 1 { + t.Fatalf("turns = %d", len(tr.Turns)) + } + if n := len(tr.Turns[0].Detail); n > 4*briefLine { + t.Errorf("summary of a 250 KB result is %d bytes", n) + } + if !strings.Contains(tr.Turns[0].Detail, "+50000 lines") { + t.Errorf("summary does not say what was left out: %q", tr.Turns[0].Detail) + } +} + +func TestTranscriptTooLargeIsRefused(t *testing.T) { + path := writeFile(t, filepath.Join(t.TempDir(), "x.jsonl"), "{}\n") + if tr := readTranscript(path, maxTranscript+1); !strings.Contains(tr.Err, "too large") { + t.Errorf("err = %q", tr.Err) + } +} + +// ── locating ───────────────────────────────────────────────────────────── + +func TestSafeSession(t *testing.T) { + for _, ok := range []string{"s1", "web:goblin", "24983541-2e59-4d47-94bb-837acf8147c3", + "019fa0f1-49de-7e33-af62-6db956589080"} { + if err := safeSession(ok); err != nil { + t.Errorf("safeSession(%q) = %v", ok, err) + } + } + for _, bad := range []string{"", "..", "a/b", `a\b`, "../../etc/passwd", "a..b", "a b", + "a*b", "a\x00b", strings.Repeat("x", 129)} { + if err := safeSession(bad); err == nil { + t.Errorf("safeSession(%q) was accepted", bad) + } + } +} + +func TestFindTranscript(t *testing.T) { + claudeRoot, codexRoot := t.TempDir(), t.TempDir() + const ( + cid = "24983541-2e59-4d47-94bb-837acf8147c3" + xid = "019fa0f1-49de-7e33-af62-6db956589080" + ) + want := writeFile(t, filepath.Join(claudeRoot, "-home-u-0x4200-cafe-proj", cid+".jsonl"), claudeJSONL) + wantX := writeFile(t, filepath.Join(codexRoot, "2026", "07", "26", + "rollout-2026-07-26T21-19-55-"+xid+".jsonl"), codexJSONL) + // a file outside every root must stay invisible + outside := writeFile(t, filepath.Join(t.TempDir(), cid+".jsonl"), claudeJSONL) + + tx := newTranscripts([]string{claudeRoot, codexRoot}) + if got := tx.find(cid, "/home/u/0x4200.cafe/proj"); got != want { + t.Errorf("with the logged cwd: %q, want %q", got, want) + } + if got := tx.find(cid, "/somewhere/else"); got != want { + t.Errorf("with a stale cwd: %q, want %q", got, want) + } + if got := tx.find(xid, ""); got != wantX { + t.Errorf("codex thread: %q, want %q", got, wantX) + } + if got := tx.find("nosuchsession", ""); got != "" { + t.Errorf("unknown session found %q", got) + } + if got := newTranscripts(nil).find(cid, filepath.Dir(outside)); got != "" { + t.Errorf("no roots configured, yet found %q", got) + } + if under(claudeRoot, outside) { + t.Error("a path outside a root passed the check") + } +} + +// A 5 MB transcript is parsed once and then only re-read when it changes. +func TestTranscriptCacheKeyedByFile(t *testing.T) { + root := t.TempDir() + path := writeFile(t, filepath.Join(root, "-repo", "s1.jsonl"), claudeJSONL) + tx := newTranscripts([]string{root}) + + first := tx.load("s1", "/repo") + if first == nil || len(first.Turns) == 0 { + t.Fatal("nothing parsed") + } + if again := tx.load("s1", "/repo"); again != first { + t.Error("an unchanged transcript was parsed twice") + } + writeFile(t, path, claudeJSONL+`{"type":"assistant","timestamp":"2026-07-20T11:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"one more thing"}]}}`+"\n") + future := time.Now().Add(time.Second) + os.Chtimes(path, future, future) + after := tx.load("s1", "/repo") + if after == first || len(after.Turns) != len(first.Turns)+1 { + t.Errorf("a changed transcript was not re-parsed: %d -> %d turns", len(first.Turns), len(after.Turns)) + } + if tx.load("nope", "/repo") != nil { + t.Error("a missing transcript should be nil, not empty") + } +} + +// ── windowing ──────────────────────────────────────────────────────────── + +func TestConvWindowIsBounded(t *testing.T) { + turns := make([]Turn, 500) + for i := range turns { + turns[i] = Turn{Role: "assistant", Text: fmt.Sprint(i)} + } + from, got := convWindowAt(turns, -1, false) + if len(got) != convWindow || from != 500-convWindow || got[len(got)-1].Text != "499" { + t.Errorf("unanchored window = %d turns from %d", len(got), from) + } + from, got = convWindowAt(turns, 200, false) + if len(got) != convWindow || from > 200 || from+len(got) <= 200 { + t.Errorf("window %d..%d does not hold turn 200", from, from+len(got)) + } + from, got = convWindowAt(turns, 498, false) + if len(got) != convWindow || from+len(got) != 500 { + t.Errorf("window near the end = %d..%d", from, from+len(got)) + } + if _, got = convWindowAt(turns, 200, true); len(got) != 500 { + t.Errorf("show all gave %d of 500 turns", len(got)) + } + if _, got = convWindowAt(turns[:10], -1, false); len(got) != 10 { + t.Errorf("short conversation was cut to %d", len(got)) + } +} + +// ── the session page ───────────────────────────────────────────────────── + +// sessionServer is testServer with a Claude Code transcript for session s1, +// filed under the repo path the log entries carry. +func sessionServer(t *testing.T) (*server, string) { + t.Helper() + sv, _, _ := testServer(t) + root := t.TempDir() + writeFile(t, filepath.Join(root, mangleDir(sv.repo), "s1.jsonl"), claudeJSONL) + sv.tx = newTranscripts([]string{root}) + return sv, root +} + +func TestSessionPageInterleavesLogAndTranscript(t *testing.T) { + sv, _ := sessionServer(t) + body := get(t, sv.handler(), "/session/s1?note=1", false).Body.String() + for _, want := range []string{ + "read lib.rs and tell me about the cache", // the conversation + "It is a two-level map.", + "vr read src/lib.rs:1-30", // a tool call, summarized + "read hello.txt:1-2", // the vr log, woven in + "second line matters", // the note itself + `data-anchor="note-1"`, // and it is what the page lands on + `class="turn t-note here"`, // marked as the anchored moment + } { + if !strings.Contains(body, want) { + t.Errorf("session page is missing %q", want) + } + } + // the whole file is never in the page + if strings.Contains(body, "use std::collections::HashMap;\n2") { + t.Error("a tool result was dumped in full") + } + // ordering: the note was logged after the first message, before the last + iFirst := strings.Index(body, "read lib.rs and tell me") + iNote := strings.Index(body, "second line matters") + iLast := strings.Index(body, "It is a two-level map.") + if !(iFirst < iNote && iNote < iLast) { + t.Errorf("turns are out of order: %d %d %d", iFirst, iNote, iLast) + } +} + +func TestSessionPageWithoutATranscript(t *testing.T) { + sv, _, _ := testServer(t) + sv.tx = newTranscripts([]string{t.TempDir()}) + body := get(t, sv.handler(), "/session/s1", false).Body.String() + if !strings.Contains(body, "no transcript found") { + t.Error("the page does not say the transcript is missing") + } + if !strings.Contains(body, sv.tx.roots[0]) { + t.Error("the page does not say where it looked") + } + if !strings.Contains(body, "read hello.txt:1-2") || !strings.Contains(body, "second line matters") { + t.Error("the vr timeline is missing from the fallback") + } +} + +func TestSessionPageRejectsUnsafeIDs(t *testing.T) { + sv, _ := sessionServer(t) + h := sv.handler() + if w := get(t, h, "/session/a%20b", false); w.Code != 400 { + t.Errorf("GET /session/a b = %d, want 400", w.Code) + } + if w := get(t, h, "/session/unknown-session", false); w.Code != 200 || + !strings.Contains(w.Body.String(), "no transcript found") { + t.Errorf("unknown session = %d", w.Code) + } +} + +// Live pages link a note to its conversation; the static export has no server +// to serve one from. +func TestConversationLinksAreLiveOnly(t *testing.T) { + sv, _ := sessionServer(t) + h := sv.handler() + if b := get(t, h, "/notes", false).Body.String(); !strings.Contains(b, `href="/session/s1?note=1#note-1"`) { + t.Error("no conversation link on a note card") + } + if b := get(t, h, "/", false).Body.String(); !strings.Contains(b, `href="/session/s1"`) { + t.Error("the agents table does not link the session") + } + if b := get(t, h, "/activity", true).Body.String(); !strings.Contains(b, `href="/session/s1"`) { + t.Error("the activity table does not link the session") + } + if b := get(t, h, "/session/s1?note=1", false).Body.String(); strings.Contains(b, ">conversation<") { + t.Error("the session page links back to itself") + } + static := &page{Root: "../"} + if got := static.ConvHref(&Note{ID: 1, Session: "s1"}); got != "" { + t.Errorf("static conversation href = %q, want none", got) + } +} |
