diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 23:43:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-03 09:54:39 -0300 |
| commit | 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch) | |
| tree | f12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/projects.go | |
| parent | a9263daee9413c5eff3c1f1bebcd224442fd8685 (diff) | |
| download | notevi-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.go | 698 |
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) - } -} |
