1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
|
// vrsite — the reading room for vr trace logs.
//
// A vr log records who read what, where, and what they thought about it. This
// program projects that log onto the jj change the code was read at: the repo
// tree with per-agent coverage colors, tree-sitter highlighted sources with
// coverage-tinted line numbers (colors mix where agents overlap), and notes —
// both the ones agents left and the ones you write in the browser.
//
// It runs as a live web app (default) or writes a static export (-out).
// Either way the log is the only state: notes are appended to it, and the
// repository is only ever read, never modified.
//
// Theme comes from a Zed theme JSON; per-agent colors come from the theme's
// player (collaborator) palette.
package main
import (
"embed"
"flag"
"fmt"
"os"
"os/user"
"path/filepath"
"time"
)
//go:embed templates assets
var tfs embed.FS
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, "vrsite: "+format+"\n", a...)
os.Exit(1)
}
func main() {
logPath := flag.String("log", "", "vr trace log (jsonl)")
repo := flag.String("repo", "", "jj repo the trace was recorded in")
addr := flag.String("addr", "127.0.0.1:4200", "address to serve on")
who := flag.String("user", "", "name your browser notes are logged under (default: $USER)")
out := flag.String("out", "", "write a static site here instead of serving")
themePath := flag.String("theme", defaultTheme("dark"), "zed theme json for the dark scheme (default: discovered, else embedded One Dark)")
themeName := flag.String("theme-name", "", "theme name inside the family")
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")
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.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]")
}
repoAbs, err := filepath.Abs(*repo)
if err != nil {
fatal("%v", err)
}
if *debounce < 0 {
fatal("debounce must not be negative")
}
theme := loadTheme(*themePath, *themeName, "dark")
light := loadTheme(*lightPath, *lightName, "light")
if *out == "" {
if *watch {
fatal("-watch is for -out builds; the served app already follows the log")
}
serve(*addr, *logPath, repoAbs, *title, username(*who), theme, light)
return
}
build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light) }
build()
if !*watch {
return
}
fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", *logPath, *debounce)
watchLog(*logPath, *debounce, build)
}
func username(flagValue string) string {
if flagValue != "" {
return flagValue
}
if u := os.Getenv("USER"); u != "" {
return u
}
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
return "web"
}
// ── watching the log (static export only) ─────────────────────────────────
type logState struct {
size int64
modTime time.Time
}
func statLog(path string) (logState, error) {
info, err := os.Stat(path)
if err != nil {
return logState{}, err
}
return logState{size: info.Size(), modTime: info.ModTime()}, nil
}
func watchLog(path string, debounce time.Duration, rebuild func()) {
watchLogUntil(path, debounce, time.Second, nil, rebuild)
}
func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) {
state, err := statLog(path)
if err != nil {
fatal("watch %s: %v", path, err)
}
ticker := time.NewTicker(poll)
defer ticker.Stop()
var timer *time.Timer
var timerC <-chan time.Time
defer func() {
if timer != nil {
timer.Stop()
}
}()
lastStatErr := ""
for {
select {
case <-done:
return
case <-ticker.C:
next, err := statLog(path)
if err != nil {
if msg := err.Error(); msg != lastStatErr {
fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", path, err)
lastStatErr = msg
}
continue
}
lastStatErr = ""
if next == state {
continue
}
state = next
if timer == nil {
timer = time.NewTimer(debounce)
timerC = timer.C
} else {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(debounce)
}
fmt.Printf("vrsite: change detected; rebuild scheduled after %s without changes\n", debounce)
case <-timerC:
fmt.Println("vrsite: rebuilding after trace changes settled")
timer = nil
timerC = nil
rebuild()
}
}
}
|