summaryrefslogtreecommitdiff
path: root/web/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 /web/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 'web/projects.go')
-rw-r--r--web/projects.go715
1 files changed, 715 insertions, 0 deletions
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)
+ }
+}