M web/handlers_repo.go => web/handlers_repo.go +10 -1
@@ 5,8 5,8 @@ import (
"net/http"
"strings"
- "sourcecraft.dev/bigbes/sr-ht-core/config"
"github.com/go-chi/chi/v5"
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
@@ 177,6 177,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
branches []browse.Branch
defBr string
commits []browse.CommitInfo
+ views []View
browseErr string
)
if sess, err := a.cfg.Browse.Open(r.Context(), repo.Path); err == nil {
@@ 190,6 191,12 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
} else {
browseErr = err.Error()
}
+ // Fingerprint the tables at the default branch to compute the
+ // optional alternative-view tabs. A browse failure here must not
+ // break the overview: on error we simply yield no view tabs.
+ if tables, err := sess.Tables(r.Context(), defBr); err == nil {
+ views = applicableViews(a.views, tables)
+ }
}
} else {
browseErr = err.Error()
@@ 204,6 211,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
Branches []browse.Branch
DefaultBranch string
Commits []browse.CommitInfo
+ Views []View
CloneURL string
BrowseError string
}{
@@ 212,6 220,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
Branches: branches,
DefaultBranch: defBr,
Commits: commits,
+ Views: views,
CloneURL: a.cloneURL(r, repo),
BrowseError: browseErr,
}
A web/handlers_view.go => web/handlers_view.go +95 -0
@@ 0,0 1,95 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// handleView renders a specialized alternative view of a repository. The view
+// is selected by the {view} URL slug and must both exist in a.views and Apply
+// to the tables at the resolved ref; otherwise the request is a 404 (the view
+// simply does not exist for this repo). The generic table browser remains
+// reachable via the tree/table routes regardless.
+//
+// The ref is taken from ?ref= (default: the repo's default branch). The view's
+// own Template() is rendered with a fixed envelope: the chrome, the repo, the
+// ref and branch list, the applicable view tabs, and the opaque value the view
+// produced from Build (as .Data).
+func (a *app) handleView(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := a.loadRepoForBrowse(w, r)
+ if !ok {
+ return
+ }
+ sess, ok := a.openBrowse(w, r, repo)
+ if !ok {
+ return
+ }
+ defer sess.Close()
+
+ ref := r.URL.Query().Get("ref")
+ if ref == "" {
+ branches, err := sess.Branches(r.Context())
+ if err != nil {
+ http.Error(w, "failed to list branches", http.StatusInternalServerError)
+ return
+ }
+ ref = browse.DefaultBranch(branches)
+ }
+
+ tables, err := sess.Tables(r.Context(), ref)
+ if err != nil {
+ if errors.Is(err, browse.ErrRefNotFound) {
+ a.notFound(w, r)
+ return
+ }
+ http.Error(w, "failed to read tables", http.StatusInternalServerError)
+ return
+ }
+
+ // Find the requested view; an unknown slug or a view that does not fingerprint
+ // these tables is reported as not found — the view does not exist here.
+ slug := chi.URLParam(r, "view")
+ var view View
+ for _, v := range a.views {
+ if v.Name() == slug {
+ view = v
+ break
+ }
+ }
+ if view == nil || !view.Applies(tables) {
+ a.notFound(w, r)
+ return
+ }
+
+ data, err := view.Build(r.Context(), sess, repo, ref, r.URL.Query())
+ if err != nil {
+ http.Error(w, "failed to build view", http.StatusInternalServerError)
+ return
+ }
+
+ // Re-list branches for the chrome/tab bar. Cheap and keeps the ref selector
+ // consistent whether or not ?ref= was supplied.
+ branches, _ := sess.Branches(r.Context())
+
+ page := struct {
+ basePage
+ Repo *core.Repo
+ Ref string
+ Branches []browse.Branch
+ Views []View
+ Data any
+ }{
+ basePage: a.newBasePage(r, view.Label()+" — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ Ref: ref,
+ Branches: branches,
+ Views: applicableViews(a.views, tables),
+ Data: data,
+ }
+ a.render(w, http.StatusOK, view.Template(), page)
+}
M web/router.go => web/router.go +27 -6
@@ 16,6 16,9 @@ type app struct {
cfg Config
templates templateSet
styleHref string
+ // views is a snapshot of the global registeredViews taken at Register time.
+ // Handlers read this (never the global) so tests can inject their own set.
+ views []View
}
// Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3
@@ 26,22 29,41 @@ type app struct {
// so a broken template fails startup loudly rather than a request later. A
// parse failure returns an error the caller must surface.
func Register(r chi.Router, cfg Config) error {
+ a, err := newApp(cfg)
+ if err != nil {
+ return err
+ }
+ a.mount(r)
+ return nil
+}
+
+// newApp validates cfg, parses templates and snapshots the view registry into a
+// ready *app. Register uses it; tests build an *app directly so they can inspect
+// and override its fields (e.g. app.views) before mounting.
+func newApp(cfg Config) (*app, error) {
if cfg.Repos == nil || cfg.Stores == nil || cfg.Browse == nil ||
cfg.Users == nil || cfg.RepoDiskPath == nil {
- return fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
+ return nil, fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
}
templates, err := loadTemplates()
if err != nil {
- return err
+ return nil, err
}
- a := &app{
+ return &app{
cfg: cfg,
templates: templates,
styleHref: discoverStyleHref(cfg.StaticDir),
- }
+ // Snapshot the registry so all handlers see a stable set and tests can
+ // override it per-app without mutating the global.
+ views: append([]View{}, registeredViews...),
+ }, nil
+}
+// mount installs every dolt.sr.ht web route onto r. Split from Register so tests
+// can mount an *app they retain a handle to.
+func (a *app) mount(r chi.Router) {
r.Get("/", a.handleIndex)
r.Get("/create", a.handleCreateForm)
r.Post("/create", a.handleCreate)
@@ 55,12 77,11 @@ func Register(r chi.Router, cfg Config) error {
r.Get("/~{user}/{db}/commit/{hash}", a.handleCommit)
r.Get("/~{user}/{db}/tree/{ref}", a.handleTree)
r.Get("/~{user}/{db}/table/{ref}/{table}", a.handleTable)
+ r.Get("/~{user}/{db}/view/{view}", a.handleView)
r.Get("/~{user}/{db}/settings", a.handleSettings)
r.Post("/~{user}/{db}/settings", a.handleSettingsPost)
r.Handle("/static/*", httpStaticHandler(a.cfg.StaticDir))
-
- return nil
}
// --- shared response helpers -------------------------------------------------
M web/templates.go => web/templates.go +44 -0
@@ 64,6 64,27 @@ func loadTemplates() (templateSet, error) {
}
set[page] = t
}
+
+ // Parse the page template of every registered view the same way, so a
+ // concrete view (e.g. the future "beads" view) becomes renderable by adding
+ // only its own web/beads.go + templates/beads.html — the embed.FS glob picks
+ // the new file up at compile time and this loop parses it, with no edit to
+ // this loader. The registry is fully populated by the init()s that ran before
+ // Register called us. A view whose Template() is already a known page (which
+ // a test may deliberately reuse, e.g. "tree.html") is skipped rather than
+ // re-parsed, so registration never clobbers a page template.
+ for _, v := range registeredViews {
+ name := v.Template()
+ if _, ok := set[name]; ok {
+ continue
+ }
+ t := template.New("layout").Funcs(funcs)
+ files := append(append([]string{}, sharedTemplates...), "templates/"+name)
+ if _, err := t.ParseFS(templateFS, files...); err != nil {
+ return nil, fmt.Errorf("web: parse view template %s: %w", name, err)
+ }
+ set[name] = t
+ }
return set, nil
}
@@ 113,7 134,30 @@ func templateFuncs(icons map[string]template.HTML) template.FuncMap {
// doltHost derives the host:port a `dolt login --auth-endpoint` expects
// from our origin URL (defaulting to :443 for https).
"doltHost": doltHost,
+ // dict builds a map from alternating key/value args, so a partial that
+ // needs several fields (e.g. the "viewtabs" tab bar) can be invoked with
+ // an inline context: {{template "viewtabs" (dict "Repo" .Repo ...)}}.
+ "dict": dict,
+ }
+}
+
+// dict builds a map[string]any from alternating key/value arguments. It powers
+// multi-field partial invocations from templates, which otherwise can pass only
+// a single pipeline value. An odd argument count or a non-string key is a
+// template authoring error and surfaces as a render error.
+func dict(kv ...any) (map[string]any, error) {
+ if len(kv)%2 != 0 {
+ return nil, fmt.Errorf("dict: expected an even number of arguments, got %d", len(kv))
+ }
+ m := make(map[string]any, len(kv)/2)
+ for i := 0; i < len(kv); i += 2 {
+ k, ok := kv[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("dict: key %d is not a string", i)
+ }
+ m[k] = kv[i+1]
}
+ return m, nil
}
// doltHost renders the host:port for `dolt login --auth-endpoint` from an origin
M web/templates/overview.html => web/templates/overview.html +4 -0
@@ 8,6 8,10 @@
<p>{{.Repo.Description}}</p>
{{end}}
+{{if .Views}}
+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}}
+{{end}}
+
<div class="clone-url">
<h4>Clone</h4>
<p>With a meta.sr.ht personal access token (Basic auth):</p>
M web/templates/partials.html => web/templates/partials.html +24 -0
@@ 8,6 8,30 @@
{{- end}}
{{- end}}
+{{/*
+ viewtabs renders the alternative-view tab bar for a repo: one tab per
+ applicable View plus a "Tables" tab for the always-available generic browser.
+ Invoke it with a dict context:
+ {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "" "Ref" .DefaultBranch)}}
+ .Current is the active view's slug ("" selects the Tables tab); .Ref is the
+ branch/ref the Tables tab links to (falls back to the overview when empty).
+*/}}
+{{define "viewtabs" -}}
+{{$repo := .Repo}}{{$current := .Current}}{{$ref := .Ref}}
+<ul class="nav nav-tabs mb-3">
+ {{range .Views}}
+ <li class="nav-item">
+ <a class="nav-link{{if eq .Name $current}} active{{end}}"
+ href="/~{{$repo.OwnerName}}/{{$repo.Name}}/view/{{.Name}}">{{.Label}}</a>
+ </li>
+ {{end}}
+ <li class="nav-item">
+ <a class="nav-link{{if eq $current ""}} active{{end}}"
+ href="{{if $ref}}/~{{$repo.OwnerName}}/{{$repo.Name}}/tree/{{$ref}}{{else}}/~{{$repo.OwnerName}}/{{$repo.Name}}{{end}}">Tables</a>
+ </li>
+</ul>
+{{- end}}
+
{{define "repoList" -}}
{{if .}}
<div class="repo-list">
A web/views.go => web/views.go +63 -0
@@ 0,0 1,63 @@
+package web
+
+import (
+ "context"
+ "net/url"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// View is a specialized, read-only rendering of a repository whose table shape
+// it recognizes. Views are registered at init time via RegisterView and chosen
+// per-repo by Applies. The generic table browser is always available too, so a
+// View never has to be exhaustive.
+//
+// A View pulls all of its data through the BrowseSession surface only (Tables,
+// Rows, Branches, Log, ...); there is no SQL engine behind a bare store. Build
+// returns an opaque value handed to the view's Template as its .Data field.
+type View interface {
+ Name() string // URL slug, e.g. "beads"; must be a valid path segment, unique
+ Label() string // human tab label, e.g. "Beads"
+ Template() string // page template filename in templates/, e.g. "beads.html"
+ Applies(tables []browse.TableInfo) bool // fingerprint by table names/columns
+ // Build produces the opaque .Data value for the view's Template. query is the
+ // request URL's query string, so a view can offer sub-modes (e.g. beads'
+ // ?issue=<id> detail pane) without a new route.
+ Build(ctx context.Context, sess BrowseSession, repo *core.Repo, ref string, query url.Values) (any, error)
+}
+
+// registeredViews is the package-global registry populated at init time by
+// RegisterView. It is read once per process: loadTemplates parses each view's
+// Template() at startup, and Register snapshots it into app.views so handlers
+// (and tests, which set app.views directly) never touch the global at request
+// time.
+var registeredViews []View
+
+// RegisterView adds v to the global registry. It is meant to be called from an
+// init() in the file that defines a concrete view, so the registry is fully
+// populated before loadTemplates runs and the first request arrives. If a view
+// with the same Name() is already registered it is replaced, so double
+// registration (e.g. from a duplicated init) is safe.
+func RegisterView(v View) {
+ for i, existing := range registeredViews {
+ if existing.Name() == v.Name() {
+ registeredViews[i] = v
+ return
+ }
+ }
+ registeredViews = append(registeredViews, v)
+}
+
+// applicableViews returns the subset of views whose Applies reports true for
+// tables, preserving registration order. It is pure: handlers pass their own
+// snapshot (app.views) so the result is deterministic and testable.
+func applicableViews(views []View, tables []browse.TableInfo) []View {
+ out := make([]View, 0, len(views))
+ for _, v := range views {
+ if v.Applies(tables) {
+ out = append(out, v)
+ }
+ }
+ return out
+}
A web/views_test.go => web/views_test.go +179 -0
@@ 0,0 1,179 @@
+package web
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// dummyView is a test-only View that fingerprints repos containing a table
+// named "issues". It renders through an EXISTING page template ("404.html") so
+// the test needs no newly embedded file. That template references only the
+// shared chrome (basePage), so it renders cleanly with the fixed view envelope
+// (which does not carry a .Tables field the way tree.html would demand). It
+// records whether Build ran.
+type dummyView struct {
+ built bool
+}
+
+func (d *dummyView) Name() string { return "issues" }
+func (d *dummyView) Label() string { return "Issues" }
+func (d *dummyView) Template() string { return "404.html" }
+
+func (d *dummyView) Applies(tables []browse.TableInfo) bool {
+ for _, t := range tables {
+ if t.Name == "issues" {
+ return true
+ }
+ }
+ return false
+}
+
+func (d *dummyView) Build(_ context.Context, sess BrowseSession, _ *core.Repo, ref string, _ url.Values) (any, error) {
+ d.built = true
+ // Pull data through the BrowseSession surface only, mirroring how a real
+ // view works, and return the tables so tree.html renders something.
+ tables, err := sess.Tables(context.Background(), ref)
+ if err != nil {
+ return nil, err
+ }
+ return tables, nil
+}
+
+func issueTables() []browse.TableInfo {
+ return []browse.TableInfo{
+ {Name: "issues", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}, RowCount: 2},
+ {Name: "labels", RowCount: 5},
+ }
+}
+
+func TestApplicableViewsFilters(t *testing.T) {
+ v := &dummyView{}
+ views := []View{v}
+
+ got := applicableViews(views, issueTables())
+ if len(got) != 1 || got[0] != v {
+ t.Fatalf("applicableViews should select the matching view; got %v", got)
+ }
+
+ // No "issues" table → no applicable views.
+ none := applicableViews(views, []browse.TableInfo{{Name: "widgets"}})
+ if len(none) != 0 {
+ t.Fatalf("applicableViews should filter out non-matching views; got %v", none)
+ }
+}
+
+func TestApplicableViewsPreservesOrder(t *testing.T) {
+ a := &dummyView{}
+ // A second always-applies view to check registration order is preserved.
+ b := alwaysView{}
+ got := applicableViews([]View{b, a}, issueTables())
+ if len(got) != 2 || got[0] != View(b) || got[1] != View(a) {
+ t.Fatalf("applicableViews should preserve order; got %v", got)
+ }
+}
+
+// alwaysView is a trivial always-applicable view used to check ordering.
+type alwaysView struct{}
+
+func (alwaysView) Name() string { return "always" }
+func (alwaysView) Label() string { return "Always" }
+func (alwaysView) Template() string { return "404.html" }
+func (alwaysView) Applies([]browse.TableInfo) bool { return true }
+func (alwaysView) Build(context.Context, BrowseSession, *core.Repo, string, url.Values) (any, error) {
+ return nil, nil
+}
+
+func TestHandleViewSelectedAndBuilt(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ tables: issueTables(),
+ }
+
+ dv := &dummyView{}
+ // Inject the view directly on the app, without polluting the global registry.
+ setViews(t, h, dv)
+
+ rec := h.do("GET", "/~alice/db/view/issues", nil, nil)
+ // A 200 (rather than the 404 an unknown/non-applicable view returns) proves
+ // the view was selected and its Template rendered; dv.built proves Build ran.
+ if rec.Code != http.StatusOK {
+ t.Fatalf("view page: got %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if !dv.built {
+ t.Fatalf("view.Build was not called")
+ }
+ // The view's Label flows into the page <title> via the envelope's basePage,
+ // so its presence confirms this view (not a fallback) produced the response.
+ if !strings.Contains(rec.Body.String(), "Issues") {
+ t.Fatalf("view page missing view label in chrome; body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleViewUnknownSlugIs404(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ tables: issueTables(),
+ }
+ setViews(t, h, &dummyView{})
+
+ rec := h.do("GET", "/~alice/db/view/nope", nil, nil)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown view slug: got %d, want 404", rec.Code)
+ }
+}
+
+func TestHandleViewNotApplicableIs404(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ // Tables lack "issues", so dummyView.Applies is false even though the slug matches.
+ h.browse.sess = &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ tables: []browse.TableInfo{{Name: "widgets"}},
+ }
+ setViews(t, h, &dummyView{})
+
+ rec := h.do("GET", "/~alice/db/view/issues", nil, nil)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("non-applicable view: got %d, want 404", rec.Code)
+ }
+}
+
+func TestOverviewShowsViewTabs(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ tables: issueTables(),
+ }
+ setViews(t, h, &dummyView{})
+
+ rec := h.do("GET", "/~alice/db", nil, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("overview: got %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "/~alice/db/view/issues") {
+ t.Fatalf("overview missing view tab link; body=%s", body)
+ }
+ if !strings.Contains(body, "nav-tabs") {
+ t.Fatalf("overview missing tab bar; body=%s", body)
+ }
+}
+
+// setViews overrides the app's view snapshot without touching the global
+// registry, so view routing/overview tabs can be exercised with a controlled
+// set. The harness retains the *app the router is mounted on.
+func setViews(t *testing.T, h *harness, vs ...View) {
+ t.Helper()
+ h.app.views = vs
+}
M web/web_test.go => web/web_test.go +20 -7
@@ 12,10 12,10 @@ import (
"testing"
"time"
- "sourcecraft.dev/bigbes/sr-ht-core/auth"
"github.com/dolthub/dolt/go/libraries/doltcore/creds"
"github.com/go-chi/chi/v5"
"github.com/vaughan0/go-ini"
+ "sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
@@ 244,8 244,12 @@ type fakeSession struct {
commits []browse.CommitInfo
tables []browse.TableInfo
rows *browse.RowPage
- summary *browse.CommitDiff
- closed bool
+ // rowsByTable, when set, lets a test return a distinct page per table name
+ // (as the beads view needs). A named miss falls back to rows. A table absent
+ // from a non-nil map is reported as ErrTableNotFound, mirroring the store.
+ rowsByTable map[string]*browse.RowPage
+ summary *browse.CommitDiff
+ closed bool
}
func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }
@@ 255,7 259,13 @@ func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.Commi
func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) {
return s.tables, nil
}
-func (s *fakeSession) Rows(_ context.Context, _, _ string, _, _ int) (*browse.RowPage, error) {
+func (s *fakeSession) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) {
+ if s.rowsByTable != nil {
+ if p, ok := s.rowsByTable[table]; ok {
+ return p, nil
+ }
+ return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
+ }
return s.rows, nil
}
func (s *fakeSession) CommitSummary(_ context.Context, _ string) (*browse.CommitDiff, error) {
@@ 288,6 298,7 @@ func (u *fakeUsers) LookupUser(_ context.Context, username string) (*core.Caller
type harness struct {
router chi.Router
+ app *app
store *fakeStore
stores *fakeStoreManager
browse *fakeBrowse
@@ 314,10 325,12 @@ func newHarness(t *testing.T) *harness {
},
}
r := chi.NewRouter()
- if err := Register(r, cfg); err != nil {
- t.Fatalf("Register: %v", err)
+ a, err := newApp(cfg)
+ if err != nil {
+ t.Fatalf("newApp: %v", err)
}
- return &harness{router: r, store: store, stores: stores, browse: fb, users: users}
+ a.mount(r)
+ return &harness{router: r, app: a, store: store, stores: stores, browse: fb, users: users}
}
// do issues a request through the router, optionally with an authenticated