// 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) } }