From 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 23:43:08 -0300 Subject: rebrand to notevi: one CLI over the jj sidecar vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) --- web/projects.go | 715 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 web/projects.go (limited to 'web/projects.go') diff --git a/web/projects.go b/web/projects.go new file mode 100644 index 0000000..1783a76 --- /dev/null +++ b/web/projects.go @@ -0,0 +1,715 @@ +// projects.go — the stateless, multi-repository front door for the notevi site. +// +// The hub is an index and dispatcher, not another database. It reconstructs +// its project list from .jj/.git markers and notevi sidecars at startup or on an +// explicit refresh. Per-project pages are still served by server, and the only +// writes are the two actions named by their buttons: initialize jj metadata or +// create the normal private notevi sidecar/config. +package web + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "html/template" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "0x4200.cafe/notevi/sidecar" +) + +type projectKind string + +const ( + projectActive projectKind = "active" + projectJJ projectKind = "jj" + projectGit projectKind = "git" +) + +type Project struct { + ID, Key, Name, Path, Relative string + Kind projectKind + Workspaces []string + Entries, Notes, Agents int + Updated, Err string +} + +type projectRuntime struct { + repo string + server *server + handler http.Handler +} + +type projectHub struct { + scanRoot, title, user string + transcriptRoots []string + theme, light *Theme + themes *themeSet + tpl *template.Template + + mu sync.RWMutex + projects map[string]*Project + order []*Project + runtimes map[string]*projectRuntime + scanned time.Time + scanErr string + + refreshMu sync.Mutex + actionMu sync.Mutex +} + +type projectsPage struct { + Title, ScanRoot, Scanned, Message, Err string + Active, Ready, Git []*Project + Theme, Light *Theme +} + +func newProjectHub(scanRoot, title, user string, transcriptRoots []string, theme, light *Theme, themes *themeSet) (*projectHub, error) { + abs, err := filepath.Abs(scanRoot) + if err != nil { + return nil, err + } + tpl, err := template.New("projects.tmpl").ParseFS(tfs, "templates/projects.tmpl") + if err != nil { + return nil, err + } + h := &projectHub{ + scanRoot: abs, title: title, user: user, transcriptRoots: transcriptRoots, + theme: theme, light: light, themes: themes, tpl: tpl, + projects: map[string]*Project{}, runtimes: map[string]*projectRuntime{}, + } + if err := h.refresh(); err != nil { + return nil, err + } + return h, nil +} + +func (h *projectHub) refresh() error { + h.refreshMu.Lock() + defer h.refreshMu.Unlock() + projects, scanErr, err := discoverProjects(h.scanRoot) + if err != nil { + return err + } + byID := make(map[string]*Project, len(projects)) + for _, p := range projects { + byID[p.ID] = p + } + h.mu.Lock() + h.projects, h.order = byID, projects + h.runtimes = map[string]*projectRuntime{} // derived cache; rebuild lazily + h.scanned, h.scanErr = time.Now(), scanErr + h.mu.Unlock() + return nil +} + +type repoCandidate struct { + path string + git bool +} + +func discoverProjects(root string) ([]*Project, string, error) { + var candidates []repoCandidate + var walkErrs []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == root { + return err + } + walkErrs = append(walkErrs, fmt.Sprintf("%s: %v", path, err)) + if d != nil && d.IsDir() { + return filepath.SkipDir + } + return nil + } + if !d.IsDir() { + return nil + } + if path != root && skipScanDir(d.Name()) { + return filepath.SkipDir + } + if markerExists(filepath.Join(path, ".jj")) { + candidates = append(candidates, repoCandidate{path: path}) + return filepath.SkipDir + } + if markerExists(filepath.Join(path, ".git")) { + candidates = append(candidates, repoCandidate{path: path, git: true}) + return filepath.SkipDir + } + return nil + }) + if err != nil { + return nil, "", err + } + + // A jj repository can have several workspaces. The repo-local config path is + // stable across them and gives the dashboard one project, not duplicate logs. + groups := map[string]*Project{} + for _, c := range candidates { + if c.git { + key := filepath.Clean(c.path) + p := &Project{ID: projectID("git", key), Key: key, Path: key, Kind: projectGit} + nameProject(p, root) + groups["git\x00"+key] = p + continue + } + out, err := jjRun(c.path, "config", "path", "--repo") + if err != nil { + walkErrs = append(walkErrs, fmt.Sprintf("%s: %v", c.path, err)) + continue + } + key := filepath.Clean(strings.TrimSpace(string(out))) + gkey := "jj\x00" + key + p := groups[gkey] + if p == nil { + p = &Project{ID: projectID("jj", key), Key: key, Path: c.path, Kind: projectJJ} + groups[gkey] = p + } + p.Workspaces = append(p.Workspaces, c.path) + if preferredWorkspace(c.path, p.Path) { + p.Path = c.path + } + } + + projects := make([]*Project, 0, len(groups)) + for _, p := range groups { + if p.Kind != projectGit { + sort.Strings(p.Workspaces) + active, err := hasSidecar(p.Path) + if err != nil { + p.Err = err.Error() + } else if active { + p.Kind = projectActive + loadProjectStats(p) + } + } + nameProject(p, root) + projects = append(projects, p) + } + sort.Slice(projects, func(i, j int) bool { + if projects[i].Kind != projects[j].Kind { + return projects[i].Kind < projects[j].Kind + } + return strings.ToLower(projects[i].Relative) < strings.ToLower(projects[j].Relative) + }) + return projects, strings.Join(walkErrs, "; "), nil +} + +func skipScanDir(name string) bool { + switch name { + case ".git", ".jj", ".cache", ".cargo", ".local", ".npm", ".rustup", ".var", + "node_modules", "target", "vendor": + return true + } + return false +} + +func markerExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func preferredWorkspace(candidate, current string) bool { + return current == "" || len(candidate) < len(current) || len(candidate) == len(current) && candidate < current +} + +func projectID(kind, key string) string { + sum := sha256.Sum256([]byte(kind + "\x00" + key)) + return fmt.Sprintf("%x", sum[:8]) +} + +func nameProject(p *Project, root string) { + p.Name = filepath.Base(p.Path) + rel, err := filepath.Rel(root, p.Path) + if err != nil || strings.HasPrefix(rel, "..") { + p.Relative = p.Path + } else if rel == "." { + p.Relative = "~" + } else { + p.Relative = "~/" + filepath.ToSlash(rel) + } +} + +// sidecarChanges looks for every description a notevi sidecar has ever had, so +// that a repository the tool has not written to since the rebrand is still +// listed as having a log rather than offered as a fresh start. +func sidecarChanges(repo string) ([]string, error) { + var ids []string + for _, description := range sidecar.Descriptions() { + revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(description)) + out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"") + if err != nil { + return nil, err + } + ids = append(ids, strings.Fields(string(out))...) + } + return ids, nil +} + +func hasSidecar(repo string) (bool, error) { + ids, err := sidecarChanges(repo) + if err != nil { + return false, err + } + switch len(ids) { + case 0: + return false, nil + case 1: + return true, nil + default: + return false, fmt.Errorf("found %d private notevi sidecars", len(ids)) + } +} + +func loadProjectStats(p *Project) { + log, err := sidecar.Open(p.Path) + if err != nil { + p.Err = err.Error() + return + } + b, err := log.Read() + if err != nil && !errors.Is(err, sidecar.ErrNoLog) { + p.Err = err.Error() + return + } + entries := parseLog(b) + p.Entries = len(entries) + agents := map[string]bool{} + var latest time.Time + for _, e := range entries { + if e.Op == "note" { + p.Notes++ + } + if e.Agent != "" { + agents[e.Agent] = true + } + if at := parseTime(e.Time); at.After(latest) { + latest = at + } + } + p.Agents = len(agents) + if latest.IsZero() { + p.Updated = "no entries yet" + } else { + p.Updated = ago(latest) + } +} + +func (h *projectHub) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", h.dashboard) + mux.HandleFunc("POST /projects/refresh", h.refreshAction) + mux.HandleFunc("POST /projects/{id}/start", h.startAction) + mux.HandleFunc("POST /git/{id}/init", h.initGitAction) + mux.HandleFunc("GET /p/{id}", h.projectSlash) + mux.HandleFunc("/p/{id}/{rest...}", h.projectRoute) + mux.HandleFunc("GET /style.css", h.styleCSS) + mux.HandleFunc("GET /themes.css", h.themeAsset) + mux.HandleFunc("GET /themes.js", h.themeAsset) + mux.HandleFunc("GET /app.js", h.asset) + mux.HandleFunc("GET /htmx.min.js", h.asset) + mux.HandleFunc("GET /bemtevi.svg", h.asset) + return mux +} + +func (h *projectHub) dashboard(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + p := projectsPage{Title: h.title, ScanRoot: h.scanRoot, Scanned: h.scanned.Format("2006-01-02 15:04:05"), + Message: r.URL.Query().Get("message"), Err: r.URL.Query().Get("error"), Theme: h.theme, Light: h.light} + if p.Err == "" { + p.Err = h.scanErr + } + for _, project := range h.order { + switch project.Kind { + case projectActive: + p.Active = append(p.Active, project) + case projectJJ: + p.Ready = append(p.Ready, project) + case projectGit: + p.Git = append(p.Git, project) + } + } + h.mu.RUnlock() + var b bytes.Buffer + if err := h.tpl.ExecuteTemplate(&b, "projects", p); err != nil { + http.Error(w, "render: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + b.WriteTo(w) +} + +func (h *projectHub) refreshAction(w http.ResponseWriter, r *http.Request) { + if !localPost(w, r) { + return + } + if err := h.refresh(); err != nil { + homeRedirect(w, r, "", err) + return + } + homeRedirect(w, r, "projects refreshed", nil) +} + +func (h *projectHub) startAction(w http.ResponseWriter, r *http.Request) { + if !localPost(w, r) { + return + } + p := h.project(r.PathValue("id")) + if p == nil || p.Kind != projectJJ { + http.NotFound(w, r) + return + } + h.actionMu.Lock() + err := ensureSidecar(p.Path) + h.actionMu.Unlock() + if err != nil { + homeRedirect(w, r, "", err) + return + } + if err := h.refresh(); err != nil { + homeRedirect(w, r, "", err) + return + } + if active := h.projectByKey(p.Key, projectActive); active != nil { + http.Redirect(w, r, "/p/"+active.ID+"/", http.StatusSeeOther) + return + } + homeRedirect(w, r, "sidecar created", nil) +} + +func (h *projectHub) initGitAction(w http.ResponseWriter, r *http.Request) { + if !localPost(w, r) { + return + } + p := h.project(r.PathValue("id")) + if p == nil || p.Kind != projectGit { + http.NotFound(w, r) + return + } + h.actionMu.Lock() + err := initColocated(p.Path) + h.actionMu.Unlock() + if err != nil { + homeRedirect(w, r, "", err) + return + } + if err := h.refresh(); err != nil { + homeRedirect(w, r, "", err) + return + } + homeRedirect(w, r, "jj initialized; the repository is ready for a notevi sidecar", nil) +} + +func localPost(w http.ResponseWriter, r *http.Request) bool { + if o := r.Header.Get("Origin"); o != "" { + if u, err := url.Parse(o); err != nil || u.Host != r.Host { + http.Error(w, "cross-origin post refused", http.StatusForbidden) + return false + } + } + return true +} + +func homeRedirect(w http.ResponseWriter, r *http.Request, message string, err error) { + q := url.Values{} + if message != "" { + q.Set("message", message) + } + if err != nil { + q.Set("error", err.Error()) + } + to := "/" + if encoded := q.Encode(); encoded != "" { + to += "?" + encoded + } + http.Redirect(w, r, to, http.StatusSeeOther) +} + +func (h *projectHub) project(id string) *Project { + h.mu.RLock() + defer h.mu.RUnlock() + return h.projects[id] +} + +func (h *projectHub) projectByKey(key string, kind projectKind) *Project { + h.mu.RLock() + defer h.mu.RUnlock() + for _, p := range h.order { + if p.Key == key && p.Kind == kind { + return p + } + } + return nil +} + +func (h *projectHub) projectSlash(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if p := h.project(id); p == nil || p.Kind != projectActive { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/p/"+id+"/", http.StatusPermanentRedirect) +} + +func (h *projectHub) projectRoute(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + runtime, err := h.runtime(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + rest := r.PathValue("rest") + clone := r.Clone(r.Context()) + u := *r.URL + u.Path, u.RawPath = "/"+rest, "" + clone.URL = &u + clone.RequestURI = u.RequestURI() + runtime.handler.ServeHTTP(w, clone) +} + +func (h *projectHub) runtime(id string) (*projectRuntime, error) { + h.mu.RLock() + if rt := h.runtimes[id]; rt != nil { + h.mu.RUnlock() + return rt, nil + } + p := h.projects[id] + h.mu.RUnlock() + if p == nil || p.Kind != projectActive { + return nil, fmt.Errorf("project not found") + } + log, err := sidecar.Open(p.Path) + if err != nil { + return nil, err + } + title := p.Name + if h.title != "" && h.title != hubDefaultTitle { + title += " — " + h.title + } + sv := newServerAt(log, p.Path, title, h.user, "/p/"+id+"/", h.theme, h.light, h.themes) + sv.tx = newTranscripts(h.transcriptRoots) + rt := &projectRuntime{repo: p.Path, server: sv, handler: sv.handler()} + h.mu.Lock() + if existing := h.runtimes[id]; existing != nil { + rt = existing + } else { + h.runtimes[id] = rt + } + h.mu.Unlock() + return rt, nil +} + +func ensureSidecar(repo string) error { + // Rename a pre-rebrand sidecar first — before taking the lock below, which + // Migrate takes too — so everything after this only sees the current names. + store, err := sidecar.Open(repo) + if err != nil { + return err + } + if _, err := store.Migrate(); err != nil { + return err + } + config, err := jjRun(repo, "config", "path", "--repo") + if err != nil { + return err + } + lockPath := filepath.Join(filepath.Dir(strings.TrimSpace(string(config))), "notevi.lock") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return err + } + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + return err + } + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck + + configuredValue, configErr := jjRun(repo, "config", "get", "revset-aliases."+sidecar.Alias) + configured := configErr == nil + var id string + if configured { + resolved, err := jjLogValue(repo, "exactly("+sidecar.Alias+", 1)", "change_id") + if err != nil { + return fmt.Errorf("repo-local revset alias %s is invalid: %w", sidecar.Alias, err) + } + valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", sidecar.Alias, strconv.Quote(sidecar.Description)) + if _, err := jjLogValue(repo, valid, "change_id"); err != nil { + return fmt.Errorf("repo-local revset alias %s=%q does not name notevi's sidecar: %w", + sidecar.Alias, strings.TrimSpace(string(configuredValue)), err) + } + id = resolved + } else { + ids, err := sidecarChanges(repo) + if err != nil { + return err + } + switch len(ids) { + case 0: + case 1: + id = ids[0] + default: + return fmt.Errorf("found %d root children described %q; cannot choose a notevi sidecar", len(ids), sidecar.Description) + } + } + if id == "" { + if err := jjMutate(repo, "new", "--no-edit", "root()", "-m", sidecar.Description); err != nil { + return err + } + ids, err := sidecarChanges(repo) + if err != nil { + return err + } + if len(ids) != 1 { + return fmt.Errorf("created %q sidecar but found %d matching root children", sidecar.Description, len(ids)) + } + id = ids[0] + } + if !configured { + if err := jjMutate(repo, "config", "set", "--repo", "revset-aliases."+sidecar.Alias, strconv.Quote(id)); err != nil { + return err + } + } + private, err := jjRun(repo, "config", "get", "git.private-commits") + if err != nil { + return err + } + expr := strings.TrimSpace(string(private)) + if !mentionsRevset(expr, sidecar.Alias) { + expr = "(" + expr + ") | " + sidecar.Alias + if err := jjMutate(repo, "config", "set", "--repo", "git.private-commits", strconv.Quote(expr)); err != nil { + return err + } + } + return nil +} + +func jjLogValue(repo, revset, template string) (string, error) { + out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", template) + if err != nil { + return "", err + } + value := strings.TrimSpace(string(out)) + if value == "" { + return "", fmt.Errorf("revision %q produced no %s", revset, template) + } + return value, nil +} + +func mentionsRevset(expr, name string) bool { + for i := 0; i+len(name) <= len(expr); i++ { + if expr[i:i+len(name)] != name { + continue + } + before := i == 0 || !revsetNameByte(expr[i-1]) + after := i+len(name) == len(expr) || !revsetNameByte(expr[i+len(name)]) + if before && after { + return true + } + } + return false +} + +func revsetNameByte(b byte) bool { + return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-' +} + +func jjMutate(repo string, args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), jjTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "jj", append([]string{"--ignore-working-copy", "--no-pager"}, args...)...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("jj %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) + } + return nil +} + +func initColocated(repo string) error { + if !markerExists(filepath.Join(repo, ".git")) { + return fmt.Errorf("%s is no longer a git repository", repo) + } + if markerExists(filepath.Join(repo, ".jj")) { + return fmt.Errorf("%s already has jj metadata", repo) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "jj", "git", "init", "--colocate") + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("jj git init --colocate: %s", strings.TrimSpace(string(out))) + } + return nil +} + +func (h *projectHub) styleCSS(w http.ResponseWriter, _ *http.Request) { + site := buildModel(h.title, nil, "") + site.Theme, site.Light = h.theme, h.light + var b bytes.Buffer + if err := renderCSS(&b, site); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + b.WriteTo(w) +} + +func (h *projectHub) themeAsset(w http.ResponseWriter, r *http.Request) { + body, ctype := h.themes.JS, "text/javascript; charset=utf-8" + if strings.HasSuffix(r.URL.Path, ".css") { + body, ctype = h.themes.CSS, "text/css; charset=utf-8" + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Cache-Control", "max-age=300") + w.Write(body) +} + +func (h *projectHub) asset(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/") + b, err := tfs.ReadFile("assets/" + name) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", assetType(name)) + w.Header().Set("Cache-Control", "max-age=300") + w.Write(b) +} + +func serveProjects(addr string, hub *projectHub) { + hub.mu.RLock() + active, ready, git := 0, 0, 0 + for _, p := range hub.order { + switch p.Kind { + case projectActive: + active++ + case projectJJ: + ready++ + case projectGit: + git++ + } + } + hub.mu.RUnlock() + fmt.Printf("notevi web: http://%s — %d projects, %d jj repos ready to start, %d git repos under %s\n", + addr, active, ready, git, hub.scanRoot) + fmt.Println("notevi web: project discovery is in memory; refresh rescans the filesystem") + if err := http.ListenAndServe(addr, hub.handler()); err != nil { + fatal("%v", err) + } +} -- cgit v1.3