summaryrefslogtreecommitdiff
path: root/vrsite/projects.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/projects.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
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) <[email protected]>
Diffstat (limited to 'vrsite/projects.go')
-rw-r--r--vrsite/projects.go698
1 files changed, 0 insertions, 698 deletions
diff --git a/vrsite/projects.go b/vrsite/projects.go
deleted file mode 100644
index 5808a92..0000000
--- a/vrsite/projects.go
+++ /dev/null
@@ -1,698 +0,0 @@
-// projects.go — the stateless, multi-repository front door for vrsite.
-//
-// The hub is an index and dispatcher, not another database. It reconstructs
-// its project list from .jj/.git markers and vr 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 vr sidecar/config.
-package main
-
-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/vr/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 := hasVRSidecar(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)
- }
-}
-
-func sidecarChanges(repo string) ([]string, error) {
- revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(sidecar.Description))
- out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"")
- if err != nil {
- return nil, err
- }
- return strings.Fields(string(out)), nil
-}
-
-func hasVRSidecar(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 vr 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)
- 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 := ensureVRSidecar(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 vr 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 != "vr projects" {
- 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 ensureVRSidecar(repo string) error {
- config, err := jjRun(repo, "config", "path", "--repo")
- if err != nil {
- return err
- }
- lockPath := filepath.Join(filepath.Dir(strings.TrimSpace(string(config))), "vr-agent-logger.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 vr'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 vr 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", "text/javascript; charset=utf-8")
- 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("vrsite: http://%s — %d projects, %d jj repos ready to start, %d git repos under %s\n",
- addr, active, ready, git, hub.scanRoot)
- fmt.Println("vrsite: project discovery is in memory; refresh rescans the filesystem")
- if err := http.ListenAndServe(addr, hub.handler()); err != nil {
- fatal("%v", err)
- }
-}