summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 17:38:06 -0300
committerGabriel Schneider <[email protected]>2026-08-02 23:11:01 -0300
commita9263daee9413c5eff3c1f1bebcd224442fd8685 (patch)
treef0a74fe1420cd9933cb2f85dc8313914aa674e94
parente16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb (diff)
downloadnotevi-a9263daee9413c5eff3c1f1bebcd224442fd8685.tar.gz
notevi-a9263daee9413c5eff3c1f1bebcd224442fd8685.zip
vrsite: toggle repository file list
-rw-r--r--vrsite/app_test.go4
-rw-r--r--vrsite/assets/app.js15
-rw-r--r--vrsite/assets/style.css.tmpl7
-rw-r--r--vrsite/assets/zig.scm65
-rw-r--r--vrsite/bench_test.go37
-rw-r--r--vrsite/go.mod5
-rw-r--r--vrsite/go.sum2
-rw-r--r--vrsite/highlight_test.go7
-rw-r--r--vrsite/markdown_test.go1
-rw-r--r--vrsite/render.go2
-rw-r--r--vrsite/templates/layout.tmpl10
11 files changed, 150 insertions, 5 deletions
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\" <world> & 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
@@ -200,6 +201,40 @@ 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;
+ }
+};
+`)},
}
func synth(chunk []byte, size int) []byte {
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 <class T> 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";}
</script>
{{if .Live}}<script src="{{.Href "asset" "htmx.min.js"}}"></script>{{end}}
<script src="{{.Href "asset" "app.js"}}" defer></script>
@@ -35,8 +36,11 @@
{{if .Live}}
<div id="activity" class="activity" hx-get="{{.Href "activity"}}?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div>
{{end}}
- <input id="filefind" class="find" type="search" placeholder="find file…" aria-label="find file" autocomplete="off" spellcheck="false">
- <div id="tree"><p class="meta hint">loading tree…</p></div>
+ <button type="button" id="filetoggle" aria-controls="filelist" aria-expanded="true">hide files</button>
+ <div id="filelist">
+ <input id="filefind" class="find" type="search" placeholder="find file…" aria-label="find file" autocomplete="off" spellcheck="false">
+ <div id="tree"><p class="meta hint">loading tree…</p></div>
+ </div>
<footer>
<span>
{{if and .Live (ne .Root "/")}}<a href="/">projects</a> · {{end}}