From a9263daee9413c5eff3c1f1bebcd224442fd8685 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 17:38:06 -0300 Subject: vrsite: toggle repository file list --- vrsite/app_test.go | 4 +++ vrsite/assets/app.js | 15 ++++++++++ vrsite/assets/style.css.tmpl | 7 +++++ vrsite/assets/zig.scm | 65 ++++++++++++++++++++++++++++++++++++++++++++ vrsite/bench_test.go | 37 ++++++++++++++++++++++++- vrsite/go.mod | 5 +++- vrsite/go.sum | 2 ++ vrsite/highlight_test.go | 7 +++++ vrsite/markdown_test.go | 1 + vrsite/render.go | 2 ++ vrsite/templates/layout.tmpl | 10 +++++-- 11 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 vrsite/assets/zig.scm diff --git a/vrsite/app_test.go b/vrsite/app_test.go index 1ee7209..876e253 100644 --- a/vrsite/app_test.go +++ b/vrsite/app_test.go @@ -391,6 +391,8 @@ func TestServerPages(t *testing.T) { h := sv.handler() for _, tc := range []struct{ path, want string }{ {"/", "second line matters"}, + {"/", `id="filetoggle"`}, + {"/", `id="filelist"`}, {"/files", "hello.txt"}, {"/tree", `class="file`}, {"/notes", "second line matters"}, @@ -398,9 +400,11 @@ func TestServerPages(t *testing.T) { {"/jj", "revset"}, {"/change/" + change, "first change"}, {"/style.css", ".am1 {"}, + {"/style.css", `data-files="hidden"`}, {"/themes.css", `:root[data-theme="zed/one-light"]`}, {"/themes.js", "VRSITE_THEMES"}, {"/app.js", "vrsite-mode"}, + {"/app.js", "vrsite-files-hidden"}, {"/htmx.min.js", "htmx"}, } { w := get(t, h, tc.path, false) diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js index 74db8d2..854959a 100644 --- a/vrsite/assets/app.js +++ b/vrsite/assets/app.js @@ -256,6 +256,21 @@ const nav = document.getElementById("nav"); const tree = document.getElementById("tree"); const state = JSON.parse(localStorage.getItem("vrsite-tree") || "{}"); +// The file tree can get out of the way without losing repository context or +// the navigation footer. Keep the choice across page loads in this browser. +const fileList = document.getElementById("filelist"); +const fileToggle = document.getElementById("filetoggle"); +if (fileList && fileToggle) { + const setFilesHidden = hidden => { + root.dataset.files = hidden ? "hidden" : "shown"; + fileToggle.setAttribute("aria-expanded", String(!hidden)); + fileToggle.textContent = hidden ? "show files" : "hide files"; + localStorage.setItem("vrsite-files-hidden", hidden ? "1" : "0"); + }; + setFilesHidden(localStorage.getItem("vrsite-files-hidden") === "1"); + fileToggle.addEventListener("click", () => setFilesHidden(root.dataset.files !== "hidden")); +} + if (nav && tree) { const base = nav.dataset.root, cur = nav.dataset.current; fetch(nav.dataset.tree).then(r => r.text()).then(t => { diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl index 60d2a2b..5ea2a40 100644 --- a/vrsite/assets/style.css.tmpl +++ b/vrsite/assets/style.css.tmpl @@ -73,6 +73,13 @@ nav > header { } nav > header a { color: var(--fg); } nav .brand { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#filetoggle { + display: block; margin: 0 0 6px auto; + background: none; color: var(--muted); border: 0; + font: inherit; font-size: 11px; padding: 1px 3px; cursor: pointer; +} +#filetoggle:hover { color: var(--fg); } +:root[data-files="hidden"] #filelist { display: none; } /* ── the current change ──────────────────────────────────────── Everything on screen is read at one change: the tree, the code, the diff --git a/vrsite/assets/zig.scm b/vrsite/assets/zig.scm new file mode 100644 index 0000000..a85383a --- /dev/null +++ b/vrsite/assets/zig.scm @@ -0,0 +1,65 @@ +; Zig highlights, from tree-sitter-grammars/tree-sitter-zig v1.1.2 +; queries/highlights.scm (MIT). Reordered for later-patterns-win, capture names +; mapped onto zed syntax keys — upstream's keyword.* split collapses to +; @keyword, which zed themes colour as one — and the #lua-match? identifier +; heuristics rewritten as #match?, which this engine evaluates. + +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +(payload "|" @punctuation.bracket) +[";" "," "." ":" "=>" "->"] @punctuation.delimiter +["=" "==" "!=" "<" ">" "<=" ">=" "+" "+%" "+|" "-" "-%" "-|" "*" "*%" "*|" + "**" "/" "%" "&" "|" "^" "~" "!" "||" "<<" "<<|" ">>" ".." ".*" ".?" "?" + "+=" "+%=" "+|=" "-=" "-%=" "-|=" "*=" "*%=" "*|=" "/=" "%=" "&=" "|=" "^=" + "<<=" "<<|=" ">>="] @operator + +["addrspace" "align" "allowzero" "and" "asm" "async" "await" "break" + "callconv" "catch" "comptime" "const" "continue" "defer" "else" "enum" + "errdefer" "error" "export" "extern" "fn" "for" "if" "inline" "linksection" + "noalias" "noinline" "nosuspend" "opaque" "or" "orelse" "packed" "pub" + "resume" "return" "struct" "suspend" "switch" "test" "threadlocal" "try" + "union" "usingnamespace" "var" "volatile" "while"] @keyword + +(builtin_type) @type +["anyframe" "anytype"] @type +(parameter type: (identifier) @type) +(variable_declaration (identifier) @type "=" + [(struct_declaration) (enum_declaration) (union_declaration) + (opaque_declaration)]) + +["null" "undefined" "unreachable"] @constant +(boolean) @constant +; an enum literal — .ok — is a member with nothing to its left +(field_expression . member: (identifier) @constant) +(enum_declaration (container_field type: (identifier) @constant)) + +(container_field name: (identifier) @property) +(field_initializer . (identifier) @property) +(field_expression (_) member: (identifier) @property) +; .x = 1 inside a Point{...} is a field, not the enum literal above +(initializer_list + (assignment_expression + left: (field_expression . member: (identifier) @property))) +(parameter name: (identifier) @variable.parameter) +(block_label (identifier) @label) +(break_label (identifier) @label) + +(function_declaration name: (identifier) @function) +(call_expression function: (identifier) @function) +(call_expression function: (field_expression member: (identifier) @function.method)) +(builtin_identifier) @function.builtin + +; Zig has no type-identifier node, only the TitleCase convention, and these +; land after the call patterns so that the convention wins the way upstream's +; first-pattern-wins order has it: std.ArrayList(u8) is a type, not a call. +((identifier) @type + (#match? @type "^[A-Z][a-zA-Z0-9_]*$")) +((identifier) @constant + (#match? @constant "^[A-Z][A-Z0-9_]+$")) + +[(integer) (float)] @number +[(string) (multiline_string) (character)] @string +(escape_sequence) @string.escape + +(comment) @comment +((comment) @comment.doc + (#match? @comment.doc "^//[/!]")) diff --git a/vrsite/bench_test.go b/vrsite/bench_test.go index 6e3f907..babf3d4 100644 --- a/vrsite/bench_test.go +++ b/vrsite/bench_test.go @@ -58,7 +58,8 @@ static GREETING: &str = "hello \"quoted\" & friends"; // 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. +// identifier they see — and zig, which names its types by casing alone, runs +// two. var langChunks = []struct { path string src []byte @@ -199,6 +200,40 @@ unsigned long entry_touch(struct cache_entry *e) { int entry_lookup(struct cache_entry *e, const char *key) { return e->key != NULL && strcmp(e->key, key) == 0; } +`)}, + {"bench.zig", []byte(`// Cache entries with hit counts. +const std = @import("std"); + +const MAX_ENTRIES: usize = 4096; + +const CacheEntry = struct { + key: []const u8, + value: []u8, + hits: u64 = 0, + + fn init(key: []const u8, value: []u8) CacheEntry { + return CacheEntry{ .key = key, .value = value }; + } + + fn stale(self: CacheEntry) bool { + return self.hits > MAX_ENTRIES; + } + + fn touch(self: *CacheEntry) u64 { + self.hits += 1; + if (self.hits % 100 == 0) { + std.debug.print("entry {s} hit {d} times \"escaped\"\n", .{ self.key, self.hits }); + } + return self.hits; + } + + fn lookup(self: CacheEntry, keys: []const []const u8) ?[]u8 { + for (keys) |k| { + if (std.mem.eql(u8, k, self.key)) return self.value; + } + return null; + } +}; `)}, } diff --git a/vrsite/go.mod b/vrsite/go.mod index 2175fcd..08e34bc 100644 --- a/vrsite/go.mod +++ b/vrsite/go.mod @@ -18,6 +18,9 @@ require ( github.com/yuin/goldmark v1.8.5 ) -require github.com/mattn/go-pointer v0.0.1 // indirect +require ( + github.com/mattn/go-pointer v0.0.1 // indirect + github.com/tree-sitter-grammars/tree-sitter-zig v1.1.2 // indirect +) replace 0x4200.cafe/vr => .. diff --git a/vrsite/go.sum b/vrsite/go.sum index 12a46d2..fd75698 100644 --- a/vrsite/go.sum +++ b/vrsite/go.sum @@ -8,6 +8,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tree-sitter-grammars/tree-sitter-zig v1.1.2 h1:j8JARutysdxMwBEfVLGx9us7cdSzD1TTui/pPLGCFDk= +github.com/tree-sitter-grammars/tree-sitter-zig v1.1.2/go.mod h1:ekWQEqj2e/gQal396f5rKJ6L14/a4bMPMqSRzmf8OZE= 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-bash v0.25.1 h1:ZD3MK4oDB5lAsFztqbdcyYEd24pxDtx3g9UOWA062rE= diff --git a/vrsite/highlight_test.go b/vrsite/highlight_test.go index b565785..e6897d1 100644 --- a/vrsite/highlight_test.go +++ b/vrsite/highlight_test.go @@ -45,6 +45,13 @@ var samples = []struct { // .h goes to the C++ grammar, but c.scm's patterns still have to reach it {"a.h", "// note\ntemplate struct Box { T v; };\n", map[string]string{"template": "keyword", "struct": "keyword", "// note": "comment", "T": "type"}}, + // zig names types by casing rather than by node, so Point and MAX are the + // query's two #match? heuristics rather than anything the grammar reports + {"a.zig", "// note\nconst std = @import(\"std\");\nconst Point = struct { x: u8 };\n" + + "pub fn main() !void {\n const MAX: u8 = 1;\n std.debug.print(\"hi\", .{ .x = MAX });\n}\n", + map[string]string{"const": "keyword", `"hi"`: "string", "// note": "comment", + "main": "function", "@import": "function", "u8": "type", "Point": "type", + "MAX": "constant", "x": "property"}}, {"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", diff --git a/vrsite/markdown_test.go b/vrsite/markdown_test.go index e8f1fe1..72f2cca 100644 --- a/vrsite/markdown_test.go +++ b/vrsite/markdown_test.go @@ -176,6 +176,7 @@ func TestMarkdownFenceLanguages(t *testing.T) { {"tsx", "function f(): void {}"}, {"c", "int f(void) { return 0; }"}, {"cpp", "int f() { return 0; }"}, {"c++", "int f() { return 0; }"}, {"h", "int f();"}, + {"zig", "fn f() void {}"}, {"json", `{"a": 1}`}, {"jsonc", `{"a": 1}`}, {"json5", `{"a": 1}`}, {"sh", "for x in a; do echo $x; done"}, {"bash", "for x in a; do echo $x; done"}, {"shell", "for x in a; do echo $x; done"}, {"zsh", "for x in a; do echo $x; done"}, diff --git a/vrsite/render.go b/vrsite/render.go index 61b1a1a..91e07b9 100644 --- a/vrsite/render.go +++ b/vrsite/render.go @@ -11,6 +11,7 @@ import ( "strings" "unsafe" + tszig "github.com/tree-sitter-grammars/tree-sitter-zig/bindings/go" 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" @@ -291,6 +292,7 @@ var grammars = []struct { // .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{".zig"}, []string{"zig"}, tszig.Language}, {[]string{".json"}, []string{"json"}, tsjson.Language}, {[]string{".sh", ".bash"}, []string{"bash"}, tsbash.Language}, } diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl index e83730a..a11ef16 100644 --- a/vrsite/templates/layout.tmpl +++ b/vrsite/templates/layout.tmpl @@ -16,7 +16,8 @@ l.rel = "stylesheet"; l.id = "themescss"; l.href = "{{.Href "asset" "themes.css"}}"; document.head.append(l); } - d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace";} + d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace"; + d.dataset.files = localStorage.getItem("vrsite-files-hidden") === "1" ? "hidden" : "shown";} {{if .Live}}{{end}} @@ -35,8 +36,11 @@ {{if .Live}}
{{end}} - -

loading tree…

+ +
+ +

loading tree…

+