diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 12:36:54 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-02 17:37:58 -0300 |
| commit | e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb (patch) | |
| tree | de0db0c369522b5bf92ff32a8351de2224c5c9dc /vrsite | |
| parent | 151ea12251ed876aa87bb486da1bc337e25369e9 (diff) | |
| download | notevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.tar.gz notevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.zip | |
Diffstat (limited to 'vrsite')
| -rw-r--r-- | vrsite/app_test.go | 54 | ||||
| -rw-r--r-- | vrsite/assets/app.js | 20 | ||||
| -rw-r--r-- | vrsite/assets/style.css.tmpl | 25 | ||||
| -rw-r--r-- | vrsite/go.mod | 3 | ||||
| -rw-r--r-- | vrsite/jj.go | 27 | ||||
| -rw-r--r-- | vrsite/log.go | 56 | ||||
| -rw-r--r-- | vrsite/main.go | 128 | ||||
| -rw-r--r-- | vrsite/main_test.go | 2 | ||||
| -rw-r--r-- | vrsite/model.go | 52 | ||||
| -rw-r--r-- | vrsite/page.go | 52 | ||||
| -rw-r--r-- | vrsite/projects.go | 698 | ||||
| -rw-r--r-- | vrsite/projects_test.go | 315 | ||||
| -rw-r--r-- | vrsite/serve.go | 88 | ||||
| -rw-r--r-- | vrsite/static.go | 4 | ||||
| -rw-r--r-- | vrsite/templates/frag-jj.tmpl | 8 | ||||
| -rw-r--r-- | vrsite/templates/layout.tmpl | 3 | ||||
| -rw-r--r-- | vrsite/templates/page-change.tmpl | 5 | ||||
| -rw-r--r-- | vrsite/templates/page-index.tmpl | 2 | ||||
| -rw-r--r-- | vrsite/templates/page-jj.tmpl | 4 | ||||
| -rw-r--r-- | vrsite/templates/page-notes.tmpl | 4 | ||||
| -rw-r--r-- | vrsite/templates/projects.tmpl | 112 |
21 files changed, 1485 insertions, 177 deletions
diff --git a/vrsite/app_test.go b/vrsite/app_test.go index 403903a..1ee7209 100644 --- a/vrsite/app_test.go +++ b/vrsite/app_test.go @@ -13,6 +13,8 @@ import ( "strings" "testing" "time" + + "0x4200.cafe/vr/sidecar" ) func logLines(lines ...string) string { return strings.Join(lines, "\n") + "\n" } @@ -369,7 +371,7 @@ func testServer(t *testing.T) (*server, string, string) { t.Fatal(err) } dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") - sv := newServer(logPath, repo, "test", "tester", dark, light, testThemes(dark, light)) + sv := newServer(newFileLog(logPath), repo, "test", "tester", dark, light, testThemes(dark, light)) return sv, logPath, change } @@ -586,6 +588,54 @@ func TestServerWritesOnlyTheLog(t *testing.T) { } } +func TestServerWritesBrowserNotesToJJSidecar(t *testing.T) { + repo, change := testRepo(t) + t.Setenv("HOME", repo) + t.Setenv("JJ_USER", "test") + t.Setenv("JJ_EMAIL", "[email protected]") + _, commit, err := resolveRevision(repo, change) + if err != nil { + t.Fatal(err) + } + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + sv := newServer(log, repo, "test", "tester", dark, light, testThemes(dark, light)) + before, err := jjRun(repo, "status") + if err != nil { + t.Fatal(err) + } + form := url.Values{"text": {"sidecar browser note"}, "file": {"hello.txt"}, "change": {change}} + r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + sv.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("POST /notes = %d: %s", w.Code, w.Body) + } + after, err := jjRun(repo, "status") + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("project working copy changed:\n--- before\n%s--- after\n%s", before, after) + } + if _, err := os.Stat(filepath.Join(repo, sidecar.LogFile)); !os.IsNotExist(err) { + t.Fatalf("sidecar materialized in project working copy: %v", err) + } + b, err := log.Read() + if err != nil { + t.Fatal(err) + } + entries := parseLog(b) + last := entries[len(entries)-1] + if last.Text != "sidecar browser note" || last.Commit != commit || len(last.Commit) != 40 { + t.Fatalf("sidecar note = %+v", last) + } +} + var agoRe = regexp.MustCompile(`\d+[smhd] ago|just now`) func TestServerHoldsNoStateOfItsOwn(t *testing.T) { @@ -598,7 +648,7 @@ func TestServerHoldsNoStateOfItsOwn(t *testing.T) { h.ServeHTTP(httptest.NewRecorder(), r) // a second server that has only ever seen the log file - fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler() + fresh := newServer(newFileLog(logPath), sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler() for _, path := range []string{"/", "/notes", "/notes?scope=all", "/code/hello.txt", "/files", "/tree", "/change/" + change} { was := agoRe.ReplaceAllString(get(t, h, path, false).Body.String(), "AGO") diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js index 696041c..74db8d2 100644 --- a/vrsite/assets/app.js +++ b/vrsite/assets/app.js @@ -22,6 +22,26 @@ setMode(root.dataset.mode || "trace"); const typing = e => /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable; +// The project hub is fully server-derived; filtering is deliberately local so +// choosing a project does not create another piece of server state. +const projectFind = document.getElementById("projectfind"); +if (projectFind) { + const apply = () => { + const q = projectFind.value.trim().toLowerCase(); + for (const card of document.querySelectorAll(".project-card")) + card.hidden = !!q && !(card.dataset.project || "").toLowerCase().includes(q); + }; + projectFind.addEventListener("input", apply); + projectFind.addEventListener("search", apply); + projectFind.addEventListener("keydown", e => { + if (e.key === "Escape") { projectFind.value = ""; apply(); projectFind.blur(); } + if (e.key === "Enter") { + const first = document.querySelector(".project-card:not([hidden]) a.project-action"); + if (first) first.click(); + } + }); +} + document.addEventListener("keydown", e => { if (e.metaKey || e.ctrlKey || e.altKey || typing(e)) return; if (e.key === "t") setMode(root.dataset.mode === "notes" ? "trace" : "notes"); diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl index 9418087..60d2a2b 100644 --- a/vrsite/assets/style.css.tmpl +++ b/vrsite/assets/style.css.tmpl @@ -537,6 +537,31 @@ table.changed .st { color: var(--muted); font-size: 11px; width: 7ch; } table.changed .st-added { color: var(--accent); } table.changed .st-deleted { color: var(--note); } +/* ── project hub ───────────────────────────────────────────── */ +.projects-main { padding-bottom: 20vh; } +.projects-main > section { max-width: 1200px; } +.project-refresh { margin: 10px 0; } +.project-refresh button, .project-card button, .project-action { + display: inline-block; background: var(--panel); color: var(--fg); + border: 1px solid var(--border); border-radius: 5px; + font: inherit; font-size: 12px; padding: 5px 10px; cursor: pointer; +} +.project-refresh button:hover, .project-card button:hover, .project-action:hover { + background: var(--hover); text-decoration: none; +} +.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; } +.project-card { min-width: 0; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); } +.project-card > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.project-card h3 { min-width: 0; margin: 0; font: 600 14px/1.3 ui-monospace, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; } +.project-path { margin: 8px 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.project-state { flex: none; padding: 1px 6px; border: 1px solid var(--border); border-radius: 99px; color: var(--muted); font-size: 10px; text-transform: uppercase; } +.project-state.active { border-color: var(--accent); color: var(--accent); } +.project-state.git { color: var(--note); } +.project-card form, .project-card .project-action { margin-top: 10px; } +.project-message { max-width: 1168px; } +.ok { margin: 12px 16px; padding: 8px 10px; border: 1px solid var(--accent); border-radius: 6px; color: var(--accent); } +.project-card[hidden] { display: none; } + /* ── agent coverage colors ────────────────────────────────── A bitmask is its agents' colors averaged. The blend is left to the browser so these rules hold for any theme: a theme only has to name diff --git a/vrsite/go.mod b/vrsite/go.mod index e4c19c8..2175fcd 100644 --- a/vrsite/go.mod +++ b/vrsite/go.mod @@ -3,6 +3,7 @@ module 0x4200.cafe/vrsite go 1.26.5 require ( + 0x4200.cafe/vr v0.0.0 github.com/BurntSushi/toml v1.6.0 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-bash v0.25.1 @@ -18,3 +19,5 @@ require ( ) require github.com/mattn/go-pointer v0.0.1 // indirect + +replace 0x4200.cafe/vr => .. diff --git a/vrsite/jj.go b/vrsite/jj.go index bb95d83..0b5cbdf 100644 --- a/vrsite/jj.go +++ b/vrsite/jj.go @@ -1,9 +1,8 @@ // jj.go — every read of the repository goes through here. // -// Two rules hold everywhere: the repo is never modified (no edit, no new, no -// checkout; --ignore-working-copy on every call so we never even snapshot), -// and nothing is written to disk — templates are passed inline with -T, so a -// query leaves no trace behind, it just renders once and is sent to the page. +// These helpers never modify project history or its working copy +// (--ignore-working-copy means they never even snapshot it). Sidecar mutation +// is isolated in the shared sidecar package. package main import ( @@ -72,21 +71,27 @@ func checkRevset(r string) error { } func resolveChange(repo, rev string) (string, error) { + change, _, err := resolveRevision(repo, rev) + return change, err +} + +func resolveRevision(repo, rev string) (change, commit string, err error) { if rev == "" { rev = "@" } if err := checkRevset(rev); err != nil { - return "", err + return "", "", err } - out, err := jjRun(repo, "log", "-r", rev, "--no-graph", "-n", "1", "-T", "change_id") + out, err := jjRun(repo, "log", "-r", "exactly(("+rev+"), 1)", "--no-graph", + "-T", "change_id ++ \"\\n\" ++ commit_id ++ \"\\n\"") if err != nil { - return "", err + return "", "", err } - id := strings.TrimSpace(string(out)) - if id == "" { - return "", fmt.Errorf("revset %q matches no change", rev) + ids := strings.Fields(string(out)) + if len(ids) != 2 { + return "", "", fmt.Errorf("revset %q did not resolve to one change and commit", rev) } - return id, nil + return ids[0], ids[1], nil } // changeBrief resolves a revset to the one change it names, with everything a diff --git a/vrsite/log.go b/vrsite/log.go new file mode 100644 index 0000000..a0a4f3e --- /dev/null +++ b/vrsite/log.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "sync" +) + +// logStore is implemented by the production jj sidecar and by the file-backed +// adapter used in tests. Append's callback makes session note numbering atomic +// with the content update. +type logStore interface { + Read() ([]byte, error) + Generation() (string, error) + Append(func(current []byte) ([]byte, error)) error + String() string +} + +type fileLog struct { + path string + mu sync.Mutex +} + +func newFileLog(path string) *fileLog { return &fileLog{path: path} } + +func (l *fileLog) String() string { return l.path } + +func (l *fileLog) Read() ([]byte, error) { return os.ReadFile(l.path) } + +func (l *fileLog) Generation() (string, error) { + info, err := os.Stat(l.path) + if err != nil { + return "", err + } + return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano()), nil +} + +func (l *fileLog) Append(makePayload func(current []byte) ([]byte, error)) error { + l.mu.Lock() + defer l.mu.Unlock() + current, err := os.ReadFile(l.path) + if err != nil && !os.IsNotExist(err) { + return err + } + payload, err := makePayload(current) + if err != nil { + return err + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(payload) + return err +} diff --git a/vrsite/main.go b/vrsite/main.go index 69c90a8..e43a8eb 100644 --- a/vrsite/main.go +++ b/vrsite/main.go @@ -7,8 +7,8 @@ // both the ones agents left and the ones you write in the browser. // // It runs as a live web app (default) or writes a static export (-out). -// Either way the log is the only state: notes are appended to it, and the -// repository is only ever read, never modified. +// Either way the log is the only state: notes are appended to a private jj +// sidecar, never to the project working copy. // // Theme comes from a Zed theme JSON; per-agent colors come from the theme's // player (collaborator) palette. Every other theme this machine has — Zed's @@ -25,6 +25,8 @@ import ( "path/filepath" "strings" "time" + + "0x4200.cafe/vr/sidecar" ) //go:embed templates assets @@ -43,30 +45,33 @@ tree with per-agent read coverage, syntax-highlighted sources, the notes agents left, the conversation each note was written in, and jj queries over the repo. usage: - vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME] - serve it, at http://127.0.0.1:4200 by default - vrsite -log FILE -repo DIR -out DIR [-watch] + vrsite [-scan-root DIR] [-addr HOST:PORT] [-user NAME] + serve every discovered project from one dashboard; scans ~ by default + vrsite -repo DIR [-addr HOST:PORT] [-user NAME] + serve one repository directly, preserving the original single-project mode + vrsite [-repo DIR] -out DIR [-watch] write a static copy instead — no server, so no comments, no live activity, no jj queries; the focus toggle, filters and file finder still work, and it holds the one change the log points at starting on a new project: - 1. the project must be a jj repo — in an existing git one: - jj git init --colocate - 2. give the agents vr and one log for the repo: - export VR_LOG=$PWD/vr-log.jsonl - and tell them to read with it; constrain/AGENTS.md in the vr repo is a - drop-in brief that does, and constrain/ can also enforce it - 3. let them work, then: - vrsite -log $PWD/vr-log.jsonl -repo $PWD + 1. open the dashboard; it discovers jj and Git repositories below ~ + 2. initialize colocated jj or start the private sidecar from its explicit + button — no project registry or scan result is written to disk + 3. give the agents vr and tell them to read with it; constrain/AGENTS.md in + the vr repo is a drop-in brief that does, and constrain/ can enforce it + 4. let them work, then refresh the dashboard when a new repo appears A log belongs to one repository. Entries carry the jj change id they were recorded at, and vrsite reads the code at those changes, so a log from another repo has nothing to show here. Point -repo at the repository the agents ran in. -Notes written in the browser are appended to the same log, under -user, at the -change being read. Nothing else is ever written: the repository is only read, -and the app keeps no state of its own. +Notes written in the browser are appended to the same private jj sidecar, +under -user, at the exact change and commit being read. The project working +copy is never changed, and the app keeps no state of its own. + +The sidecar has no bookmark and is local-only. It is not included in a fresh +clone, so back up the jj repository or export the site when the trace matters. Session transcripts (the conversation behind a note) are looked up by session id under -transcripts; Claude Code and Codex are found automatically. @@ -80,8 +85,12 @@ flags: ` func main() { - logPath := flag.String("log", "", "vr trace log (jsonl)") - repo := flag.String("repo", "", "jj repo the trace was recorded in") + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + repo := flag.String("repo", "", "serve one jj repo directly (with -out, default: current directory)") + scanRoot := flag.String("scan-root", home, "live dashboard root to scan for jj and Git repositories") addr := flag.String("addr", "127.0.0.1:4200", "address to serve on") who := flag.String("user", "", "name your browser notes are logged under (default: $USER)") out := flag.String("out", "", "write a static site here instead of serving") @@ -99,42 +108,54 @@ func main() { flag.PrintDefaults() } flag.Parse() - if *logPath == "" || *repo == "" { - missing := "-log and -repo are required" - if *logPath != "" { - missing = "-repo is required: the repository the log was recorded in" - } else if *repo != "" { - missing = "-log is required: the jsonl file vr appends to" - } - fmt.Fprintf(os.Stderr, "vrsite: %s\n\n", missing) - flag.Usage() - os.Exit(2) - } - repoAbs, err := filepath.Abs(*repo) - if err != nil { - fatal("%v", err) - } if *debounce < 0 { fatal("debounce must not be negative") } theme := loadTheme(*themePath, *themeName, "dark") light := loadTheme(*lightPath, *lightName, "light") themes := newThemeSet(theme, light) + if *out == "" && *watch { + fatal("-watch is for -out builds; the served app already follows the log") + } - if *out == "" { - if *watch { - fatal("-watch is for -out builds; the served app already follows the log") + // Explicit -repo and every static export retain the original one-repository + // path. A bare live invocation is the new, stateless project dashboard. + if *repo == "" && *out == "" { + hubTitle := *title + if hubTitle == "vr trace" { + hubTitle = "vr projects" } - serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes) + hub, err := newProjectHub(*scanRoot, hubTitle, username(*who), strings.Split(*roots, ","), theme, light, themes) + if err != nil { + fatal("discover projects: %v", err) + } + serveProjects(*addr, hub) + return + } + + repoPath := *repo + if repoPath == "" { + repoPath = "." + } + repoAbs, err := filepath.Abs(repoPath) + if err != nil { + fatal("%v", err) + } + log, err := sidecar.Open(repoAbs) + if err != nil { + fatal("open jj sidecar: %v", err) + } + if *out == "" { + serve(*addr, log, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes) return } - build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light, themes) } + build := func() { buildSite(log, repoAbs, *out, *title, theme, light, themes) } build() if !*watch { return } - fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", *logPath, *debounce) - watchLog(*logPath, *debounce, build) + fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", log, *debounce) + watchLog(log, *debounce, build) } func username(flagValue string) string { @@ -152,27 +173,14 @@ func username(flagValue string) string { // ── watching the log (static export only) ───────────────────────────────── -type logState struct { - size int64 - modTime time.Time -} - -func statLog(path string) (logState, error) { - info, err := os.Stat(path) - if err != nil { - return logState{}, err - } - return logState{size: info.Size(), modTime: info.ModTime()}, nil -} - -func watchLog(path string, debounce time.Duration, rebuild func()) { - watchLogUntil(path, debounce, time.Second, nil, rebuild) +func watchLog(log logStore, debounce time.Duration, rebuild func()) { + watchLogUntil(log, debounce, time.Second, nil, rebuild) } -func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) { - state, err := statLog(path) +func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) { + state, err := log.Generation() if err != nil { - fatal("watch %s: %v", path, err) + fatal("watch %s: %v", log, err) } ticker := time.NewTicker(poll) @@ -192,10 +200,10 @@ func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct return case <-ticker.C: - next, err := statLog(path) + next, err := log.Generation() if err != nil { if msg := err.Error(); msg != lastStatErr { - fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", path, err) + fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", log, err) lastStatErr = msg } continue diff --git a/vrsite/main_test.go b/vrsite/main_test.go index 7e715b9..b7c93c3 100644 --- a/vrsite/main_test.go +++ b/vrsite/main_test.go @@ -18,7 +18,7 @@ func TestWatchLogDebouncesChanges(t *testing.T) { rebuilt := make(chan struct{}, 2) go func() { defer close(stopped) - watchLogUntil(logPath, 60*time.Millisecond, 5*time.Millisecond, done, func() { + watchLogUntil(newFileLog(logPath), 60*time.Millisecond, 5*time.Millisecond, done, func() { rebuilt <- struct{}{} }) }() diff --git a/vrsite/model.go b/vrsite/model.go index b580b49..10f3f1d 100644 --- a/vrsite/model.go +++ b/vrsite/model.go @@ -2,14 +2,13 @@ // // Everything the site shows is derived here from the vr log alone: agents and // their colors, per-file coverage bitmasks, notes, and live session activity. -// The repo is never written to; the log is the only state. +// The private jj sidecar is the only state. package main import ( "encoding/json" "fmt" "html/template" - "os" "sort" "strings" "time" @@ -29,6 +28,7 @@ type Entry struct { Dir string `json:"dir,omitempty"` Rev string `json:"rev,omitempty"` Change string `json:"change,omitempty"` + Commit string `json:"commit,omitempty"` File string `json:"file,omitempty"` Start int `json:"start,omitempty"` End int `json:"end,omitempty"` @@ -47,16 +47,16 @@ type Agent struct { } type Note struct { - ID int // position in the log, for anchors - NoteID int // the agent's own per-session number - Agent *Agent - Session, Model, Reason string - File, Kind, Text, Change, Rev string - Start, End int - ReplyTo int - Replies, Mentions []*Note // notes that point back to this one - At time.Time - When, Ago string + ID int // position in the log, for anchors + NoteID int // the agent's own per-session number + Agent *Agent + Session, Model, Reason string + File, Kind, Text, Change, Commit, Rev string + Start, End int + ReplyTo int + Replies, Mentions []*Note // notes that point back to this one + At time.Time + When, Ago string } // ThreadNode is the tree-shaped rendering of the note DAG. A note reached by @@ -164,7 +164,7 @@ func buildModel(title string, entries []Entry, only string) *Site { at := parseTime(e.Time) n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session, Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change, - Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo, + Commit: e.Commit, Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo, Kind: e.Type, Text: e.Text, At: at, When: stamp(at), Ago: ago(at)} s.Notes = append(s.Notes, n) @@ -547,7 +547,7 @@ func describe(e Entry) string { return e.Op } -// ── log file ────────────────────────────────────────────────────────────── +// ── sidecar JSONL ───────────────────────────────────────────────────────── func parseLog(b []byte) []Entry { var entries []Entry @@ -563,28 +563,20 @@ func parseLog(b []byte) []Entry { return entries } -func readLog(path string) []Entry { - b, err := os.ReadFile(path) +func readLog(log logStore) []Entry { + b, err := log.Read() if err != nil { fatal("%v", err) } return parseLog(b) } -// appendEntry adds one line to the log. O_APPEND plus a single write keeps it -// interleaving safely with vr processes writing the same file. -func appendEntry(path string, e Entry) error { - b, err := json.Marshal(e) - if err != nil { - return err - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - _, err = f.Write(append(b, '\n')) - return err +func appendEntry(log logStore, e *Entry) error { + return log.Append(func(current []byte) ([]byte, error) { + e.NoteID = nextNoteID(parseLog(current), e.Session) + b, err := json.Marshal(e) + return append(b, '\n'), err + }) } // ── time ────────────────────────────────────────────────────────────────── diff --git a/vrsite/page.go b/vrsite/page.go index cca0c58..1570d45 100644 --- a/vrsite/page.go +++ b/vrsite/page.go @@ -17,7 +17,7 @@ type page struct { Site *Site Title string Kind string // index | file | files | notes | note | jj | change | session - Root string // static: relative prefix; live: "/" + Root string // static: relative prefix; live: route prefix ending in "/" Live bool Rev string // revision being browsed ("" = the change the log points at) ChangeID string // resolved change id for Rev — what pages name and link @@ -63,6 +63,7 @@ type page struct { // JJView is one rendered jj query: what was asked, and what came back. type JJView struct { + Root string Kind string // log | op Revset string AtOp string @@ -95,7 +96,7 @@ func (p *page) paramsWith(scope string) string { func (p *page) ScopeHref(scope string) string { path := p.Path if path == "" { - path = "/" + path = p.liveRoot() } return path + p.paramsWith(scope) } @@ -118,7 +119,21 @@ func (p *page) JumpPath() string { return p.Path } } - return "/files" + return p.liveRoot() + "files" +} + +func (p *page) liveRoot() string { + root := p.Root + if root == "" { + return "/" + } + if !strings.HasPrefix(root, "/") { + root = "/" + root + } + if !strings.HasSuffix(root, "/") { + root += "/" + } + return root } // Href maps a logical destination to a URL for this page's mode. Kinds the @@ -129,29 +144,38 @@ func (p *page) Href(kind string, arg ...string) string { a = arg[0] } if p.Live { + root := p.liveRoot() switch kind { case "": - return "/" + p.params() + return root + p.params() case "notes": - return "/notes" + p.params() + return root + "notes" + p.params() + case "notes-list": + return root + "notes/list" + p.params() case "files": - return "/files" + p.params() + return root + "files" + p.params() case "tree": - return "/tree" + p.params() + return root + "tree" + p.params() case "jj": - return "/jj" + return root + "jj" + case "jj-log": + return root + "jj/log" + case "jj-op": + return root + "jj/op" + case "activity": + return root + "activity" case "code": - return "/code/" + escPath(a) + p.params() + return root + "code/" + escPath(a) + p.params() case "note": - return "/note/" + a + return root + "note/" + a case "change": - return "/change/" + a + return root + "change/" + a case "session": - return "/session/" + escPath(a) + return root + "session/" + escPath(a) case "asset": - return "/" + a + return root + a } - return "/" + return root } switch kind { case "": diff --git a/vrsite/projects.go b/vrsite/projects.go new file mode 100644 index 0000000..5808a92 --- /dev/null +++ b/vrsite/projects.go @@ -0,0 +1,698 @@ +// 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) + } +} diff --git a/vrsite/projects_test.go b/vrsite/projects_test.go new file mode 100644 index 0000000..79fc6bc --- /dev/null +++ b/vrsite/projects_test.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "0x4200.cafe/vr/sidecar" +) + +func projectTestEnv(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("JJ_USER", "test") + t.Setenv("JJ_EMAIL", "[email protected]") +} + +func runAt(t *testing.T, dir, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s in %s: %v\n%s", name, strings.Join(args, " "), dir, err, out) + } + return string(out) +} + +func initJJAt(t *testing.T, dir string) (change, commit string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + runAt(t, dir, "jj", "git", "init") + if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("one\ntwo\n"), 0o644); err != nil { + t.Fatal(err) + } + runAt(t, dir, "jj", "describe", "-m", "test project") + change, commit, err := resolveRevision(dir, "@") + if err != nil { + t.Fatal(err) + } + return change, commit +} + +func addTestSidecar(t *testing.T, repo, change, commit string) { + t.Helper() + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + line, err := json.Marshal(Entry{ + Time: "2026-08-02T12:00:00Z", Op: "note", Session: "s1", Agent: "codex", + Dir: repo, Rev: "@", Change: change, Commit: commit, File: "hello.txt", + Start: 1, End: 1, NoteID: 1, Text: "project note", + }) + if err != nil { + t.Fatal(err) + } + if err := log.Append(func([]byte) ([]byte, error) { return []byte(line), nil }); err != nil { + t.Fatal(err) + } +} + +func initGitAt(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + runAt(t, dir, "git", "init", "-q") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("git only\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func testProjectHub(t *testing.T, home string) *projectHub { + t.Helper() + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + h, err := newProjectHub(home, "vr projects", "tester", nil, dark, light, testThemes(dark, light)) + if err != nil { + t.Fatal(err) + } + return h +} + +func projectOfKind(t *testing.T, h *projectHub, kind projectKind, name string) *Project { + t.Helper() + h.mu.RLock() + defer h.mu.RUnlock() + for _, p := range h.order { + if p.Kind == kind && p.Name == name { + return p + } + } + t.Fatalf("no %s project named %s", kind, name) + return nil +} + +func TestDiscoverProjectsClassifiesAndDeduplicates(t *testing.T) { + if _, err := exec.LookPath("jj"); err != nil { + t.Skip("jj not installed") + } + home := t.TempDir() + projectTestEnv(t, home) + active := filepath.Join(home, "src", "active") + change, commit := initJJAt(t, active) + addTestSidecar(t, active, change, commit) + workspace := filepath.Join(home, "src", "active-workspace") + runAt(t, active, "jj", "workspace", "add", workspace) + initJJAt(t, filepath.Join(home, "src", "ready")) + initGitAt(t, filepath.Join(home, "src", "git-only")) + + projects, scanErr, err := discoverProjects(home) + if err != nil || scanErr != "" { + t.Fatalf("discover = %v, %q", err, scanErr) + } + if len(projects) != 3 { + t.Fatalf("projects = %d, want 3: %+v", len(projects), projects) + } + counts := map[projectKind]int{} + for _, p := range projects { + counts[p.Kind]++ + if p.Kind == projectActive { + if p.Entries != 1 || p.Notes != 1 || p.Agents != 1 || len(p.Workspaces) != 2 { + t.Fatalf("active project = %+v", p) + } + } + } + if counts[projectActive] != 1 || counts[projectJJ] != 1 || counts[projectGit] != 1 { + t.Fatalf("classification = %+v", counts) + } +} + +func TestProjectHubRoutesAnExistingLogUnderPrefix(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "alpha") + change, commit := initJJAt(t, repo) + addTestSidecar(t, repo, change, commit) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectActive, "alpha") + h := hub.handler() + + for _, tc := range []struct{ path, want string }{ + {"/", "alpha"}, + {"/", `id="themepick"`}, + {"/", `data-js="/themes.js"`}, + {"/", `l.href = "/themes.css"`}, + {"/p/" + p.ID + "/", "project note"}, + {"/p/" + p.ID + "/code/hello.txt", "two"}, + {"/p/" + p.ID + "/style.css", ".project-grid"}, + {"/p/" + p.ID + "/jj", "/p/" + p.ID + "/jj/log"}, + } { + w := get(t, h, tc.path, false) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), tc.want) { + t.Errorf("GET %s = %d, want body containing %q\n%s", tc.path, w.Code, tc.want, w.Body.String()) + } + } + body := get(t, h, "/p/"+p.ID+"/", false).Body.String() + for _, want := range []string{"/p/" + p.ID + "/style.css", "/p/" + p.ID + "/activity", ">projects</a>"} { + if !strings.Contains(body, want) { + t.Errorf("prefixed page lacks %q", want) + } + } + + form := url.Values{"text": {"written through the project route"}, "change": {change}} + r := httptest.NewRequest("POST", "/p/"+p.ID+"/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/p/"+p.ID+"/notes" { + t.Fatalf("prefixed POST = %d, location %q: %s", w.Code, w.Header().Get("Location"), w.Body.String()) + } + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + b, err := log.Read() + if err != nil { + t.Fatal(err) + } + entries := parseLog(b) + if last := entries[len(entries)-1]; last.Text != "written through the project route" || last.Change != change { + t.Fatalf("prefixed POST recorded %+v", last) + } +} + +func TestProjectHubStartsAnEmptyPrivateSidecar(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "ready") + initJJAt(t, repo) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectJJ, "ready") + beforeStatus, _ := jjRun(repo, "status") + _, beforeCommit, err := resolveRevision(repo, "@") + if err != nil { + t.Fatal(err) + } + + r := httptest.NewRequest("POST", "/projects/"+p.ID+"/start", nil) + w := httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/p/") { + t.Fatalf("start = %d, location %q: %s", w.Code, w.Header().Get("Location"), w.Body.String()) + } + opened := get(t, hub.handler(), w.Header().Get("Location"), false) + if opened.Code != http.StatusOK || !strings.Contains(opened.Body.String(), "ready") { + t.Fatalf("new empty project page = %d: %s", opened.Code, opened.Body.String()) + } + active, err := hasVRSidecar(repo) + if err != nil || !active { + t.Fatalf("sidecar = %v, %v", active, err) + } + afterStatus, _ := jjRun(repo, "status") + _, afterCommit, err := resolveRevision(repo, "@") + if err != nil { + t.Fatal(err) + } + if string(afterStatus) != string(beforeStatus) || afterCommit != beforeCommit { + t.Fatal("starting the sidecar changed the project working copy") + } + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + if b, err := log.Read(); err != nil || len(b) != 0 { + t.Fatalf("new sidecar log = %q, %v", b, err) + } + if _, err := os.Stat(filepath.Join(repo, sidecar.LogFile)); !os.IsNotExist(err) { + t.Fatalf("sidecar materialized in the project: %v", err) + } + private, err := jjRun(repo, "config", "get", "git.private-commits") + if err != nil || !mentionsRevset(string(private), sidecar.Alias) { + t.Fatalf("private commits = %q, %v", private, err) + } +} + +func TestEnsureVRSidecarRefusesAConflictingAlias(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "conflict") + initJJAt(t, repo) + runAt(t, repo, "jj", "config", "set", "--repo", "revset-aliases."+sidecar.Alias, `"root()"`) + + err := ensureVRSidecar(repo) + if err == nil || !strings.Contains(err.Error(), "does not name vr's sidecar") { + t.Fatalf("ensure with conflicting alias = %v", err) + } + if active, err := hasVRSidecar(repo); err != nil || active { + t.Fatalf("conflicting alias created a sidecar: %v, %v", active, err) + } + alias := strings.TrimSpace(runAt(t, repo, "jj", "config", "get", "revset-aliases."+sidecar.Alias)) + if alias != "root()" { + t.Fatalf("conflicting alias was overwritten: %q", alias) + } +} + +func TestProjectHubInitializesGitThenRefreshes(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "git-only") + initGitAt(t, repo) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectGit, "git-only") + + r := httptest.NewRequest("POST", "/git/"+p.ID+"/init", nil) + w := httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("init = %d: %s", w.Code, w.Body.String()) + } + if !markerExists(filepath.Join(repo, ".jj")) { + t.Fatal("colocated jj metadata was not created") + } + projectOfKind(t, hub, projectJJ, "git-only") + if got := string(mustRead(t, filepath.Join(repo, "README.md"))); got != "git only\n" { + t.Fatalf("working file changed: %q", got) + } + + late := filepath.Join(home, "code", "appeared-later") + initJJAt(t, late) + r = httptest.NewRequest("POST", "/projects/refresh", nil) + w = httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("refresh = %d", w.Code) + } + projectOfKind(t, hub, projectJJ, "appeared-later") +} + +func TestProjectHubRefusesCrossOriginMutation(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "ready") + initJJAt(t, repo) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectJJ, "ready") + r := httptest.NewRequest("POST", "/projects/"+p.ID+"/start", nil) + r.Header.Set("Origin", "https://elsewhere.example") + w := httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-origin start = %d", w.Code) + } + if active, err := hasVRSidecar(repo); err != nil || active { + t.Fatalf("cross-origin request created sidecar: %v, %v", active, err) + } +} diff --git a/vrsite/serve.go b/vrsite/serve.go index 31217af..746abac 100644 --- a/vrsite/serve.go +++ b/vrsite/serve.go @@ -3,9 +3,9 @@ // The server owns nothing. Three rules keep it that way, and app_test.go holds // each of them: // -// - The log is the only thing written. Notes typed in the browser are -// appended to it exactly as vr appends them; the repository is only ever -// read (see jj.go), and no file of the app's own is ever created. +// - The log sidecar is the only thing written. Notes typed in the browser are +// appended to it exactly as vr appends them; project history and the main +// working copy are only ever read (see jj.go). // - Memory is a cache, never a source. The model, the per-change // projections, file lists and rendered pages are all derived from the log // and the repo, so a restart changes nothing anyone can see. @@ -27,7 +27,6 @@ import ( "html/template" "net/http" "net/url" - "os" "strconv" "strings" "sync" @@ -43,18 +42,18 @@ const liveWindow = 5 * time.Minute const renderCacheMax = 64 type server struct { - logPath, repo, title, user, session string - tpl *template.Template - hl *highlighter - theme, light *Theme - themes *themeSet - tx *transcripts + log logStore + repo, title, user, session, base string + tpl *template.Template + hl *highlighter + theme, light *Theme + themes *themeSet + tx *transcripts mu sync.Mutex site *Site entries []Entry - off int64 - state logState + state string gen int def string // resolved change the log points at models map[string]*Site // scope key -> the log projected onto it @@ -65,12 +64,17 @@ type server struct { // scopeAll is the key of the unscoped model — the whole log, every change. const scopeAll = "*" -func newServer(logPath, repo, title, user string, theme, light *Theme, themes *themeSet) *server { +func newServer(log logStore, repo, title, user string, theme, light *Theme, themes *themeSet) *server { + return newServerAt(log, repo, title, user, "/", theme, light, themes) +} + +func newServerAt(log logStore, repo, title, user, base string, theme, light *Theme, themes *themeSet) *server { if themes == nil { themes = &themeSet{} } + base = (&page{Root: base}).liveRoot() hl := newHighlighter(theme, light) - sv := &server{logPath: logPath, repo: repo, title: title, user: user, + sv := &server{log: log, repo: repo, title: title, user: user, base: base, session: "web:" + user, theme: theme, light: light, themes: themes, tpl: newTemplates(hl), models: map[string]*Site{}, trees: map[string][]*File{}, rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())} @@ -79,11 +83,10 @@ func newServer(logPath, repo, title, user string, theme, light *Theme, themes *t return sv } -// reload re-reads the log if it changed. It appends from the last offset when -// the file only grew — the common case, since vr and the web app both append. +// reload re-reads the log when the sidecar commit id changes. func (sv *server) reload(force bool) { - st, err := statLog(sv.logPath) - if err == nil && st == sv.state && !force { + state, err := sv.log.Generation() + if err == nil && state == sv.state && !force { return } if err != nil { @@ -92,22 +95,17 @@ func (sv *server) reload(force bool) { } return } - if st.size < sv.off { - sv.off, sv.entries = 0, nil // truncated or rewritten - } - f, err := os.Open(sv.logPath) + b, err := sv.log.Read() if err != nil { + if sv.site == nil { + sv.state = state + sv.site = sv.dress(buildModel(sv.title, nil, "")) + sv.gen++ + } return } - defer f.Close() - buf := make([]byte, st.size-sv.off) - n, _ := f.ReadAt(buf, sv.off) - buf = buf[:n] - if i := bytes.LastIndexByte(buf, '\n'); i >= 0 { // ignore a partial last line - sv.off += int64(i + 1) - sv.entries = append(sv.entries, parseLog(buf[:i+1])...) - } - sv.state = st + sv.entries = parseLog(b) + sv.state = state sv.site = sv.dress(buildModel(sv.title, sv.entries, "")) sv.gen++ sv.def = "" @@ -257,8 +255,9 @@ func (sv *server) fileAt(site *Site, gen int, commit, change, path string) *File func (sv *server) newPage(r *http.Request, kind, title string) (*page, *Site, error) { full, _, def := sv.snap() q := r.URL.Query() - p := &page{Site: full, Kind: kind, Title: title, Root: "/", Live: true, - User: sv.user, ChangeID: def, Default: def, Path: r.URL.Path, Scope: "change"} + path := sv.base + strings.TrimPrefix(r.URL.Path, "/") + p := &page{Site: full, Kind: kind, Title: title, Root: sv.base, Live: true, + User: sv.user, ChangeID: def, Default: def, Path: path, Scope: "change"} rev := strings.TrimSpace(q.Get("rev")) target := rev if target == "" { @@ -540,7 +539,7 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { } target = fallback } - change, err := resolveChange(sv.repo, target) + change, commit, err := resolveRevision(sv.repo, target) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -556,14 +555,13 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { e := Entry{ Time: time.Now().Format(time.RFC3339), Op: "note", Session: sv.session, Agent: sv.user, Dir: sv.repo, - Rev: rev, Change: change, File: strings.TrimSpace(r.FormValue("file")), + Rev: rev, Change: change, Commit: commit, File: strings.TrimSpace(r.FormValue("file")), Start: start, End: end, ReplyTo: replyTo, Type: strings.TrimSpace(r.FormValue("kind")), Text: text, } sv.mu.Lock() - e.NoteID = nextNoteID(sv.entries, sv.session) - err = appendEntry(sv.logPath, e) + err = appendEntry(sv.log, &e) if err == nil { sv.reload(true) } @@ -583,12 +581,12 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { if !htmxReq(r) { back := r.FormValue("back") if back == "" { - back = "/notes" + back = sv.base + "notes" } http.Redirect(w, r, back, http.StatusSeeOther) return } - p := &page{Site: site, Kind: "file", Root: "/", Live: true, User: sv.user, Facets: site.Facets()} + p := &page{Site: site, Kind: "file", Root: sv.base, Live: true, User: sv.user, Facets: site.Facets()} if e.File != "" { p.File = site.Files[e.File] } @@ -609,7 +607,7 @@ func (sv *server) activity(w http.ResponseWriter, r *http.Request) { if !compact { limit = 20 } - p := &page{Site: site, Live: true, Compact: compact, Window: "5 min", + p := &page{Site: site, Root: sv.base, Live: true, Compact: compact, Window: "5 min", Sessions: site.Sessions(time.Now(), liveWindow, limit)} sv.frag(w, "activity", p) } @@ -638,7 +636,7 @@ const defaultLimit = 50 func (sv *server) jjView(r *http.Request) *JJView { q := r.URL.Query() - v := &JJView{Kind: q.Get("kind"), Revset: strings.TrimSpace(q.Get("revset")), + v := &JJView{Root: sv.base, Kind: q.Get("kind"), Revset: strings.TrimSpace(q.Get("revset")), AtOp: strings.TrimSpace(q.Get("at_op")), Limit: atoiDefault(q.Get("limit"), defaultLimit)} if v.Limit < 1 || v.Limit > 1000 { v.Limit = defaultLimit @@ -678,7 +676,7 @@ func (sv *server) jjLog(w http.ResponseWriter, r *http.Request) { func (sv *server) jjOp(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - v := &JJView{Kind: "op", Limit: atoiDefault(q.Get("limit"), defaultLimit)} + v := &JJView{Root: sv.base, Kind: "op", Limit: atoiDefault(q.Get("limit"), defaultLimit)} if v.Limit < 1 || v.Limit > 1000 { v.Limit = defaultLimit } @@ -751,15 +749,15 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) { w.Write(b) } -func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { - sv := newServer(logPath, repo, title, user, theme, light, themes) +func serve(addr string, log logStore, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { + sv := newServer(log, repo, title, user, theme, light, themes) sv.tx = newTranscripts(roots) site, _, def := sv.snap() fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n", addr, len(site.Notes), len(site.Agents), repo, short(def)) fmt.Printf("vrsite: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n", theme.Name, light.Name, len(sv.themes.List), sv.themes.count("zed"), sv.themes.count("helix")) - fmt.Printf("vrsite: comments are appended to %s; the repo is never written to\n", logPath) + fmt.Printf("vrsite: comments are appended to %s; the project working copy is never written to\n", log) fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", ")) if err := http.ListenAndServe(addr, sv.handler()); err != nil { fatal("%v", err) diff --git a/vrsite/static.go b/vrsite/static.go index b299975..0a8eec0 100644 --- a/vrsite/static.go +++ b/vrsite/static.go @@ -32,9 +32,9 @@ func run(dir string, name string, args ...string) []byte { return out } -func buildSite(logPath, repoAbs, out, title string, theme, light *Theme, themes *themeSet) { +func buildSite(log logStore, repoAbs, out, title string, theme, light *Theme, themes *themeSet) { start := time.Now() - entries := readLog(logPath) + entries := readLog(log) ranked := buildModel(title, entries, "").Ranked if len(ranked) == 0 { fatal("log has no jj change ids") diff --git a/vrsite/templates/frag-jj.tmpl b/vrsite/templates/frag-jj.tmpl index b31af53..61040dc 100644 --- a/vrsite/templates/frag-jj.tmpl +++ b/vrsite/templates/frag-jj.tmpl @@ -8,7 +8,7 @@ {{else if .Op}} <div class="jjrow"> <span class="graph">{{.Graph}}</span> - <a class="id" href="/jj?at_op={{.ID}}" title="run the commit log as of this operation">{{.Short}}</a> + <a class="id" href="{{$.Root}}jj?at_op={{.ID}}" title="run the commit log as of this operation">{{.Short}}</a> <span class="when">{{.When}}</span> <span class="who">{{.Author}}</span> <span class="desc">{{.Desc}}</span> @@ -17,17 +17,17 @@ {{else}} <div class="jjrow"> <span class="graph">{{.Graph}}</span> - <a class="id" href="/change/{{.ID}}" title="{{.ID}}">{{.Short}}</a> + <a class="id" href="{{$.Root}}change/{{.ID}}" title="{{.ID}}">{{.Short}}</a> <code class="commit">{{.CommitShort}}</code> <span class="when">{{.When}}</span> <span class="who">{{.Author}}</span> {{if .Refs}}<span class="refs">{{.Refs}}</span>{{end}} {{if .Flags}}<span class="flags">{{.Flags}}</span>{{end}} - <a class="desc" href="/files?rev={{.ID}}" title="browse the tree at this change">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</a> + <a class="desc" href="{{$.Root}}files?rev={{.ID}}" title="browse the tree at this change">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</a> </div> {{end}} {{end}} </div> {{else if not .Err}}<p class="meta hint">no revisions matched</p>{{end}} -{{if .AtOp}}<p class="meta">as of operation <code>{{short .AtOp}}</code> · <a href="/jj?kind=op">back to the op log</a></p>{{end}} +{{if .AtOp}}<p class="meta">as of operation <code>{{short .AtOp}}</code> · <a href="{{.Root}}jj?kind=op">back to the op log</a></p>{{end}} {{end}} diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl index 268b5b6..e83730a 100644 --- a/vrsite/templates/layout.tmpl +++ b/vrsite/templates/layout.tmpl @@ -33,12 +33,13 @@ </header> {{template "changebar" .}} {{if .Live}} - <div id="activity" class="activity" hx-get="/activity?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div> + <div id="activity" class="activity" hx-get="{{.Href "activity"}}?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div> {{end}} <input id="filefind" class="find" type="search" placeholder="find file…" aria-label="find file" autocomplete="off" spellcheck="false"> <div id="tree"><p class="meta hint">loading tree…</p></div> <footer> <span> + {{if and .Live (ne .Root "/")}}<a href="/">projects</a> · {{end}} <a href="{{.Href "notes"}}">notes ({{len .Site.Notes}})</a> · <a href="{{.Href "files"}}">files</a>{{if .Live}} · <a href="{{.Href "jj"}}">jj</a>{{end}} diff --git a/vrsite/templates/page-change.tmpl b/vrsite/templates/page-change.tmpl index 08310e5..8641da7 100644 --- a/vrsite/templates/page-change.tmpl +++ b/vrsite/templates/page-change.tmpl @@ -1,5 +1,6 @@ {{define "page-change"}} {{$c := .Change}} +{{$p := .}} <header class="filehead"> <h1>{{$c.Row.Short}} <span class="meta">{{$c.Row.Flags}}</span></h1> <p class="meta"> @@ -14,7 +15,7 @@ {{if $c.Parents}} <p class="meta">parents: - {{range $c.Parents}}<a href="/change/{{.ID}}"><code>{{.Short}}</code></a> {{.Desc}} {{end}} + {{range $c.Parents}}<a href="{{$p.Href "change" .ID}}"><code>{{.Short}}</code></a> {{.Desc}} {{end}} </p> {{end}} @@ -24,7 +25,7 @@ <tbody> {{range $c.Files}} <tr><td class="st st-{{.Status}}">{{.Status}}</td> - <td><a href="/code/{{.Path}}?rev={{$c.Row.ID}}">{{.Path}}</a></td></tr> + <td><a href="{{$p.Href "code" .Path}}">{{.Path}}</a></td></tr> {{end}} </tbody> </table> diff --git a/vrsite/templates/page-index.tmpl b/vrsite/templates/page-index.tmpl index bd377ac..93cad6b 100644 --- a/vrsite/templates/page-index.tmpl +++ b/vrsite/templates/page-index.tmpl @@ -12,7 +12,7 @@ {{if .Live}} <section> <h2>Live <span class="meta">— agents reading this codebase</span></h2> - <div id="activity-full" hx-get="/activity" hx-trigger="load, every 3s" hx-swap="innerHTML"> + <div id="activity-full" hx-get="{{.Href "activity"}}" hx-trigger="load, every 3s" hx-swap="innerHTML"> <p class="meta hint">…</p> </div> </section> diff --git a/vrsite/templates/page-jj.tmpl b/vrsite/templates/page-jj.tmpl index 5c1381c..24df596 100644 --- a/vrsite/templates/page-jj.tmpl +++ b/vrsite/templates/page-jj.tmpl @@ -8,12 +8,12 @@ <section class="jjpage"> <form class="jjform" method="get" action="{{.Href "jj"}}" - hx-get="/jj/log" hx-target="#jjout" hx-swap="innerHTML" hx-indicator="#jjbusy"> + hx-get="{{.Href "jj-log"}}" hx-target="#jjout" hx-swap="innerHTML" hx-indicator="#jjbusy"> <input type="hidden" name="kind" value="log"> <input type="search" name="revset" value="{{.JJ.Revset}}" placeholder="revset — e.g. @ | ancestors(trunk(), 10) | description(glob:'*fix*')" autocomplete="off" spellcheck="false"> <input type="number" name="limit" value="{{.JJ.Limit}}" min="1" max="1000" aria-label="limit" title="limit"> <button type="submit">jj log</button> - <button type="button" hx-get="/jj/op" hx-target="#jjout" hx-include="closest form" hx-indicator="#jjbusy" title="operation log">op log</button> + <button type="button" hx-get="{{.Href "jj-op"}}" hx-target="#jjout" hx-include="closest form" hx-indicator="#jjbusy" title="operation log">op log</button> <span id="jjbusy" class="busy">·</span> </form> <p class="meta hint"> diff --git a/vrsite/templates/page-notes.tmpl b/vrsite/templates/page-notes.tmpl index 242c137..ebd5865 100644 --- a/vrsite/templates/page-notes.tmpl +++ b/vrsite/templates/page-notes.tmpl @@ -16,7 +16,7 @@ {{if .Live}}<a href="{{.ScopeHref "all"}}">{{.Elsewhere}} more at other changes</a> {{else}}{{.Elsewhere}} more at other changes are not in this export{{end}}{{end}} {{end}} - {{if .Live}} · anything you add here is appended to the log at this change — the repo is never touched{{end}} + {{if .Live}} · anything you add here is appended to the private log sidecar at this change — the project working copy is never touched{{end}} </p> </header> @@ -29,7 +29,7 @@ {{end}} <form class="filters" id="notefilters" method="get" action="{{.Href "notes"}}" - {{if .Live}}hx-get="/notes/list" hx-target="#notelist" hx-swap="innerHTML" + {{if .Live}}hx-get="{{.Href "notes-list"}}" hx-target="#notelist" hx-swap="innerHTML" hx-trigger="input delay:250ms, change, search" hx-indicator="#notesbusy"{{end}}> <input type="search" name="text" value="{{.Filter.Text}}" placeholder="search note text…" autocomplete="off"> <input type="search" name="file" value="{{.Filter.File}}" placeholder="file path…" list="filelist" autocomplete="off"> diff --git a/vrsite/templates/projects.tmpl b/vrsite/templates/projects.tmpl new file mode 100644 index 0000000..3e3c670 --- /dev/null +++ b/vrsite/templates/projects.tmpl @@ -0,0 +1,112 @@ +{{define "projects"}}<!doctype html> +<html lang="en" data-live="1"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{.Title}}</title> +<link rel="stylesheet" href="/style.css"> +<script> +// Match project pages: wear the saved theme before first paint, and load the +// installed-theme stylesheet now only when that saved choice requires it. +{const d = document.documentElement, t = localStorage.getItem("vrsite-theme"); + if (t && t !== "system") d.dataset.theme = t; + if (t && !/^(system|dark|light)$/.test(t)) { + const l = document.createElement("link"); + l.rel = "stylesheet"; l.id = "themescss"; l.href = "/themes.css"; + document.head.append(l); + } + d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace";} +</script> +<script src="/app.js" defer></script> +</head> +<body class="projects"> +<nav aria-label="projects" id="nav"> + <header><a class="brand" href="/">{{.Title}}</a></header> + <p class="meta">one process · every local vr sidecar</p> + <input id="projectfind" class="find" type="search" placeholder="find project…" aria-label="find project" autocomplete="off" spellcheck="false"> + <form method="post" action="/projects/refresh" class="project-refresh"> + <button type="submit">refresh repositories</button> + </form> + <p class="meta">scans <code>{{.ScanRoot}}</code></p> + <p class="meta">last refresh {{.Scanned}}</p> + <footer> + <span>selection and scan results stay in memory</span> + <div class="themepick" id="themepick" + data-js="/themes.js" data-css="/themes.css" + data-dark="{{.Theme.Name}}" data-light="{{.Light.Name}}" + data-darkid="{{.Theme.ID}}" data-lightid="{{.Light.ID}}"> + <button type="button" id="themebtn" aria-haspopup="listbox" aria-expanded="false" + title="theme — every one installed here (T)">theme</button> + <div class="thememenu" id="thememenu" hidden> + <input id="themefind" type="search" placeholder="filter themes…" aria-label="filter themes" + role="combobox" aria-controls="themelist" aria-autocomplete="list" + autocomplete="off" spellcheck="false"> + <ul id="themelist" role="listbox" aria-label="themes"></ul> + <p class="meta hint">↑↓ preview · enter keeps · esc puts it back</p> + </div> + </div> + </footer> +</nav> + +<main class="projects-main"> + <header class="filehead"> + <h1>Projects</h1> + <p class="meta">Choose a vr log, start one in a jj repository, or colocate jj with an existing Git repository.</p> + </header> + {{if .Message}}<p class="ok project-message">{{.Message}}</p>{{end}} + {{if .Err}}<p class="err project-message">{{.Err}}</p>{{end}} + + <section> + <h2>Logs <span class="meta">— {{len .Active}} repositories with a private sidecar</span></h2> + <div class="project-grid"> + {{range .Active}} + <article class="project-card" data-project="{{.Name}} {{.Relative}}"> + <header><h3><a href="/p/{{.ID}}/">{{.Name}}</a></h3><span class="project-state active">log</span></header> + <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p> + <p class="meta">{{.Entries}} entries · {{.Notes}} notes · {{.Agents}} agents · {{.Updated}}</p> + {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} jj workspaces share this log</p>{{end}} + {{if .Err}}<p class="err">{{.Err}}</p>{{end}} + <a class="project-action" href="/p/{{.ID}}/">open project</a> + </article> + {{end}} + {{if not .Active}}<p class="meta hint">no vr sidecars found yet</p>{{end}} + </div> + </section> + + <section> + <h2>Ready to start <span class="meta">— {{len .Ready}} jj repositories without a vr sidecar</span></h2> + <div class="project-grid"> + {{range .Ready}} + <article class="project-card" data-project="{{.Name}} {{.Relative}}"> + <header><h3>{{.Name}}</h3><span class="project-state">jj</span></header> + <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p> + {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} workspaces</p>{{end}} + {{if .Err}}<p class="err">{{.Err}}</p>{{end}} + <form method="post" action="/projects/{{.ID}}/start"> + <button type="submit">start private sidecar</button> + </form> + </article> + {{end}} + {{if not .Ready}}<p class="meta hint">every detected jj repository already has a sidecar</p>{{end}} + </div> + </section> + + <section> + <h2>Git repositories <span class="meta">— {{len .Git}} without jj metadata</span></h2> + <p class="meta">Initializing adds colocated <code>.jj</code> metadata; it does not create a sidecar until you choose “start private sidecar”.</p> + <div class="project-grid"> + {{range .Git}} + <article class="project-card" data-project="{{.Name}} {{.Relative}}"> + <header><h3>{{.Name}}</h3><span class="project-state git">git</span></header> + <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p> + <form method="post" action="/git/{{.ID}}/init"> + <button type="submit">initialize colocated jj</button> + </form> + </article> + {{end}} + {{if not .Git}}<p class="meta hint">no Git-only repositories detected</p>{{end}} + </div> + </section> +</main> +</body> +</html>{{end}} |
