diff options
Diffstat (limited to 'web/web.go')
| -rw-r--r-- | web/web.go | 245 |
1 files changed, 245 insertions, 0 deletions
diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..ad7bd9c --- /dev/null +++ b/web/web.go @@ -0,0 +1,245 @@ +// Package web is notevi's reading room: "notevi web" served or exported. +// +// A notevi log records who read what, where, and what they thought about it. +// This package 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 a private jj +// sidecar, never to the project working copy. +// +// Theme comes from a Zed theme JSON; per-agent colors come from the theme's +// player (collaborator) palette. Every other theme this machine has — Zed's +// and Helix's both — is offered in the picker, previewed as you move through +// it, and remembered in the browser. +package web + +import ( + "embed" + "flag" + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + "time" + + "0x4200.cafe/notevi/sidecar" +) + +//go:embed templates assets +var tfs embed.FS + +// Titles for a single repository's trace and for the dashboard over all of them. +const ( + defaultTitle = "notevi trace" + hubDefaultTitle = "notevi projects" +) + +func fatal(format string, a ...any) { + fmt.Fprintf(os.Stderr, "notevi web: "+format+"\n", a...) + os.Exit(1) +} + +const usageText = `notevi web — the reading room for notevi trace logs. + +notevi logs every file a coding agent reads, greps and notes, one JSON line +each, keyed to the jj change the code was read at. This 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. + +usage: + notevi web [-scan-root DIR] [-addr HOST:PORT] [-user NAME] + serve every discovered project from one dashboard; scans ~ by default + notevi web -repo DIR [-addr HOST:PORT] [-user NAME] + serve one repository directly, preserving the original single-project mode + notevi web [-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. open the dashboard; it discovers jj and Git repositories below ~ + 2. initialize colocated jj or start the private sidecar from its explicit + button — no project registry or scan result is written to disk + 3. give the agents notevi and tell them to read with it; constrain/AGENTS.md + in the notevi repo is a drop-in brief that does, and constrain/ enforces it + 4. let them work, then refresh the dashboard when a new repo appears + +A log belongs to one repository. Entries carry the jj change id they were +recorded at, and the site 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 private jj sidecar, +under -user, at the exact change and commit being read. The project working +copy is never changed, and the app keeps no state of its own. + +The sidecar has no bookmark and is local-only. It is not included in a fresh +clone, so back up the jj repository or export the site when the trace matters. + +Session transcripts (the conversation behind a note) are looked up by session +id under -transcripts; Claude Code and Codex are found automatically. + +The site is built with the two themes -theme and -light-theme name. Every +other theme installed on this machine — Zed families and Helix themes both — +is offered in the picker at the bottom of the tree: it previews as you move +through it, and the browser remembers what you settled on. + +flags: +` + +// Main runs the "notevi web" subcommand; args are everything after "web". +func Main(args []string) { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + fs := flag.NewFlagSet("notevi web", flag.ExitOnError) + repo := fs.String("repo", "", "serve one jj repo directly (with -out, default: current directory)") + scanRoot := fs.String("scan-root", home, "live dashboard root to scan for jj and Git repositories") + addr := fs.String("addr", "127.0.0.1:4200", "address to serve on") + who := fs.String("user", "", "name your browser notes are logged under (default: $USER)") + out := fs.String("out", "", "write a static site here instead of serving") + themePath := fs.String("theme", defaultTheme("dark"), "zed theme json for the dark scheme (default: discovered, else embedded One Dark)") + themeName := fs.String("theme-name", "", "theme name inside the family") + lightPath := fs.String("light-theme", defaultTheme("light"), "zed theme json for the light scheme (default: discovered, else embedded One Light)") + lightName := fs.String("light-theme-name", "", "theme name inside the light family") + title := fs.String("title", defaultTitle, "site title") + roots := fs.String("transcripts", strings.Join(defaultTranscriptRoots(), ","), + "comma-separated roots to look for harness session transcripts in") + watch := fs.Bool("watch", false, "with -out: watch the trace log and rebuild after changes settle") + debounce := fs.Duration("debounce", 10*time.Minute, "quiet period before a watched rebuild") + fs.Usage = func() { + fmt.Fprint(fs.Output(), usageText) + fs.PrintDefaults() + } + fs.Parse(args) + if *debounce < 0 { + fatal("debounce must not be negative") + } + theme := loadTheme(*themePath, *themeName, "dark") + light := loadTheme(*lightPath, *lightName, "light") + themes := newThemeSet(theme, light) + if *out == "" && *watch { + fatal("-watch is for -out builds; the served app already follows the log") + } + + // Explicit -repo and every static export retain the original one-repository + // path. A bare live invocation is the new, stateless project dashboard. + if *repo == "" && *out == "" { + hubTitle := *title + if hubTitle == defaultTitle { + hubTitle = hubDefaultTitle + } + hub, err := newProjectHub(*scanRoot, hubTitle, username(*who), strings.Split(*roots, ","), theme, light, themes) + if err != nil { + fatal("discover projects: %v", err) + } + serveProjects(*addr, hub) + return + } + + repoPath := *repo + if repoPath == "" { + repoPath = "." + } + repoAbs, err := filepath.Abs(repoPath) + if err != nil { + fatal("%v", err) + } + log, err := sidecar.Open(repoAbs) + if err != nil { + fatal("open jj sidecar: %v", err) + } + if *out == "" { + serve(*addr, log, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes) + return + } + build := func() { buildSite(log, repoAbs, *out, *title, theme, light, themes) } + build() + if !*watch { + return + } + fmt.Printf("notevi web: watching %s (rebuild after %s without changes)\n", log, *debounce) + watchLog(log, *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) ───────────────────────────────── + +func watchLog(log logStore, debounce time.Duration, rebuild func()) { + watchLogUntil(log, debounce, time.Second, nil, rebuild) +} + +func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) { + state, err := log.Generation() + if err != nil { + fatal("watch %s: %v", log, 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 := log.Generation() + if err != nil { + if msg := err.Error(); msg != lastStatErr { + fmt.Fprintf(os.Stderr, "notevi web: watch %s: %v\n", log, 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("notevi web: change detected; rebuild scheduled after %s without changes\n", debounce) + + case <-timerC: + fmt.Println("notevi web: rebuilding after trace changes settled") + timer = nil + timerC = nil + rebuild() + } + } +} |
