diff options
Diffstat (limited to 'vrsite/main.go')
| -rw-r--r-- | vrsite/main.go | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/vrsite/main.go b/vrsite/main.go new file mode 100644 index 0000000..0a525be --- /dev/null +++ b/vrsite/main.go @@ -0,0 +1,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() + } + } +} |
