summaryrefslogtreecommitdiff
path: root/vrsite
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 12:36:54 -0300
committerGabriel Schneider <[email protected]>2026-08-02 17:37:58 -0300
commite16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb (patch)
treede0db0c369522b5bf92ff32a8351de2224c5c9dc /vrsite
parent151ea12251ed876aa87bb486da1bc337e25369e9 (diff)
downloadnotevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.tar.gz
notevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.zip
Diffstat (limited to 'vrsite')
-rw-r--r--vrsite/app_test.go54
-rw-r--r--vrsite/assets/app.js20
-rw-r--r--vrsite/assets/style.css.tmpl25
-rw-r--r--vrsite/go.mod3
-rw-r--r--vrsite/jj.go27
-rw-r--r--vrsite/log.go56
-rw-r--r--vrsite/main.go128
-rw-r--r--vrsite/main_test.go2
-rw-r--r--vrsite/model.go52
-rw-r--r--vrsite/page.go52
-rw-r--r--vrsite/projects.go698
-rw-r--r--vrsite/projects_test.go315
-rw-r--r--vrsite/serve.go88
-rw-r--r--vrsite/static.go4
-rw-r--r--vrsite/templates/frag-jj.tmpl8
-rw-r--r--vrsite/templates/layout.tmpl3
-rw-r--r--vrsite/templates/page-change.tmpl5
-rw-r--r--vrsite/templates/page-index.tmpl2
-rw-r--r--vrsite/templates/page-jj.tmpl4
-rw-r--r--vrsite/templates/page-notes.tmpl4
-rw-r--r--vrsite/templates/projects.tmpl112
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}}