diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 29407ac..e7b60c9 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -905,7 +905,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps http.Redirect(w, r, urlPath+"/", http.StatusFound) return } - handler.ServeDirectory(cfg, w, r) + handler.ServeDirectory(cfg, appsSrv, w, r) return } http.Error(w, "Not Found", http.StatusNotFound) @@ -980,7 +980,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps http.Redirect(w, r, urlPath+"/", http.StatusFound) return } - handler.ServeDirectory(cfg, w, r) + handler.ServeDirectory(cfg, appsSrv, w, r) return } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 5c3f4f4..ecb00e5 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -39,7 +39,15 @@ func safeJoin(fsRoot, relPath string) (string, bool) { // ServeDirectory handles a request for a directory path. // If Accept: application/json → return Caddy-compatible JSON listing. // Otherwise → return minimal HTML. -func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) { +// +// appsSrv is optional: when non-nil, HTML responses resolve the +// `browse` tool through the apps subsystem so a `.zddc apps:` cascade +// entry can override the embedded bytes (handy for live alpha-dev +// iteration: point apps.browse: at a path source and every ./build is +// served from disk without recompiling the binary). When nil, the +// embedded copy is served directly — same behavior as before the +// cascade hook was added. +func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) { urlPath := r.URL.Path if !strings.HasSuffix(urlPath, "/") { http.Redirect(w, r, urlPath+"/", http.StatusFound) @@ -150,12 +158,17 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } - // Browser HTML fallback: serve the embedded `browse` tool. It's a - // single-file SPA whose autoDetectServerMode loads the JSON listing - // for the current directory and renders it as a sortable, filterable - // tree. Same bytes that get served at //browse.html — but at - // the bare directory URL too, so any zddc-served folder presents a - // usable file browser to anyone who navigates to it. + // Browser HTML fallback: serve the `browse` tool. By default it's + // the embedded copy (single-file SPA whose autoDetectServerMode + // loads the JSON listing for the current directory and renders it + // as a sortable, filterable tree). A `.zddc apps: browse:` entry + // up the chain can override with a path or URL source — when + // appsSrv is wired up, delegate to it so cascade entries are + // honored at directory URLs too (not just //browse.html). + if appsSrv != nil { + appsSrv.Serve(w, r, "browse", chain, absDir) + return + } body := apps.EmbeddedBytes("browse") if len(body) == 0 { // Bootstrap state: a fresh build hasn't populated browse.html diff --git a/zddc/internal/handler/directory_test.go b/zddc/internal/handler/directory_test.go index 11400df..a5d53c4 100644 --- a/zddc/internal/handler/directory_test.go +++ b/zddc/internal/handler/directory_test.go @@ -57,7 +57,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) { // Anonymous: empty email in context. req = req.WithContext(context.WithValue(req.Context(), EmailKey, "")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (root is public); body = %s", @@ -70,7 +70,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) { req.Header.Set("Accept", "application/json") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) @@ -100,7 +100,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) { req.Header.Set("Accept", "application/json") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "admin@example.com")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code != http.StatusOK { t.Fatalf("admin status = %d, want 200", rec.Code) @@ -120,7 +120,7 @@ func TestServeDirectoryRootIsPublic(t *testing.T) { req.Header.Set("Accept", "application/json") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code != http.StatusForbidden { t.Errorf("private subdir for anonymous: status = %d, want 403", rec.Code) @@ -162,7 +162,7 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) { req.Header.Set("Accept", "text/html") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String()) @@ -177,7 +177,7 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) { req.Header.Set("Accept", "application/json") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) @@ -196,7 +196,7 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) { req.Header.Set("Accept", "text/html") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code == http.StatusFound { t.Fatalf("got 302 to %q for non-table dir", rec.Header().Get("Location")) @@ -225,7 +225,7 @@ func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) { req.Header.Set("Accept", "text/html") req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) rec := httptest.NewRecorder() - ServeDirectory(cfg, rec, req) + ServeDirectory(cfg, nil, rec, req) if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String()) diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index bfafbb9..b2ae7e3 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-beta · 2026-05-11 · lens-mesa-chalk + v0.0.17-alpha · 2026-05-11 17:07:33 · c87fb7f-dirty
diff --git a/zddc/internal/zddc/validate.go b/zddc/internal/zddc/validate.go index fc0e741..75132e1 100644 --- a/zddc/internal/zddc/validate.go +++ b/zddc/internal/zddc/validate.go @@ -8,7 +8,12 @@ import ( // AppNames is the canonical set of app HTML files the server resolves // via the apps fetch+cache subsystem. Order is stable for reproducible // admin-UI rendering. -var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing"} +// +// All eight HTML tools belong here — including browse, form, and tables. +// Omitting any of them means the apps cascade (.zddc apps:) silently +// short-circuits to embedded for that name, defeating live-dev +// path-source overrides. +var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing", "browse", "form", "tables"} // AppsDefaultKey is the special apps-map key that provides the baseline // URL prefix and channel for any app not overridden per-name. Cascades @@ -232,7 +237,7 @@ func ValidateFile(zf ZddcFile) []FieldError { if !IsValidAppsKey(app) { errs = append(errs, FieldError{ Field: fmt.Sprintf("apps.%s", app), - Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing)", app), + Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing, browse, form, tables)", app), }) continue }