~bigbes/sr-ht-dolt

4520d5cf9385a3a7c18037e0c3dba95ad1874b35 — Eugene Blikh 30 days ago ba34443
feat(web): pluggable alternative-view registry for repositories

A repository can now expose specialized, read-only "views" chosen by the
shape of its tables, while the generic table browser stays available as a
fallback. A View fingerprints the tables (Applies), builds a model from
the browse layer (Build), and renders its own template; views register at
init time via RegisterView and are dispatched by slug at
/~user/db/view/{slug}. The overview shows a tab per applicable view.

The template loader parses each registered view's template with the
shared chrome, so a new view plugs in by adding two files (its .go with
init()+RegisterView and its .html) plus nothing else — no edits to the
registry, router, loader, or handlers. Build receives the request query
values so a view can offer sub-modes (e.g. a detail pane). No SQL engine
is involved; views read through the existing BrowseSession surface.
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