feat(zddc-server): reviewing/ slash form serves browse, no-slash serves mdedit

Apply the same slash-vs-no-slash convention to reviewing/ that already
governs the other three canonical project folders:

  /<project>/reviewing       → mdedit (default tool, via DefaultAppAt)
  /<project>/reviewing/      → browse (HTML) — shows the aggregator's
                                virtual <tracking>/ entries as a tree
  /<project>/reviewing/?json → aggregator JSON (handler.ServeReviewing)

Browse fetches the JSON listing for the URL it was loaded from, so
loading browse.html at /<project>/reviewing/ triggers a JSON request
back through the dispatcher → ServeReviewing → aggregator output.
Browse then renders the virtual <tracking>/ entries as clickable
folders. Clicking a tracking folder navigates to the per-submittal
view; clicking received/ or staged/ exits the virtual subtree
into canonical archive/ or staging/ paths via the polyfill's
explicit-url support.

The HTML branch in the reviewing dispatcher block was previously
calling appsSrv.Serve(..., "mdedit", ...) for trailing-slash URLs;
now it falls through to the canonical-folder block which routes to
ServeDirectory's HTML default (embedded browse.html).

Test: TestDispatchEmptyCanonicalProjectFolders extended with the
slash/<stage> → browse subtests, mirroring the no-slash → default
app set. All four canonical folders now have symmetric coverage of
both shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-10 06:44:35 -05:00
parent 94323ea356
commit 2f93fc1854
2 changed files with 34 additions and 18 deletions

View file

@ -842,15 +842,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
} }
} }
// Reviewing aggregator. <project>/reviewing/[<tracking>/] is // Reviewing aggregator. <project>/reviewing/[<tracking>/] is
// a virtual view. With trailing slash: // a virtual view. The shape rule mirrors the other canonical
// - JSON request → aggregator listing (handler.ServeReviewing) // folders (slash → browse, no-slash → default tool):
// - HTML request → mdedit, rooted at the reviewing/ path. // - JSON request, any depth → aggregator listing (handler.ServeReviewing)
// mdedit's polyfill then fetches the JSON // - HTML, no slash → mdedit (default tool, via DefaultAppAt)
// listing on its own. // - HTML, with slash → browse.html (via ServeDirectory).
// Without trailing slash, depth-3 (reviewing/<tracking>) 301s // browse fetches JSON which routes back
// to the slash form; depth-2 (reviewing) falls through to the // through here to ServeReviewing.
// canonical-folder block below where DefaultAppAt routes to // Depth-3 no-slash (reviewing/<tracking>) 301s to the slash form.
// mdedit and the no-slash branch serves it directly. // Depth-2 no-slash (reviewing) falls through to the canonical-
// folder block below where DefaultAppAt routes to mdedit.
if r.Method == http.MethodGet || r.Method == http.MethodHead { if r.Method == http.MethodGet || r.Method == http.MethodHead {
if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok { if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok {
if !strings.HasSuffix(urlPath, "/") { if !strings.HasSuffix(urlPath, "/") {
@ -859,21 +860,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
// Depth-2 no-slash falls through to canonical-folder block. // Depth-2 no-slash falls through to canonical-folder block.
} else { } else if strings.Contains(r.Header.Get("Accept"), "application/json") {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, proj)) chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, proj))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
if strings.Contains(r.Header.Get("Accept"), "application/json") { handler.ServeReviewing(cfg, w, r, proj, tracking)
handler.ServeReviewing(cfg, w, r, proj, tracking) return
return
}
if appsSrv != nil {
appsSrv.Serve(w, r, "mdedit", chain, absPath)
return
}
} }
// HTML trailing-slash falls through to canonical-folder
// block → ServeDirectory → embedded browse.html.
} }
} }
// Canonical project-root folder fallback. <project>/{archive, // Canonical project-root folder fallback. <project>/{archive,

View file

@ -548,6 +548,25 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
}) })
} }
// Trailing-slash form on a canonical folder serves the browse
// app for HTML requests — same convention as the existing IsDir
// branch. The slash-vs-no-slash distinction is the user's signal:
// "show me the directory contents" vs "open the default tool".
for _, stage := range []string{"working", "staging", "archive", "reviewing"} {
t.Run("slash/"+stage+" → browse", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
req.Header.Set("Accept", "text/html")
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "ZDDC Browse") {
t.Errorf("%s/ HTML response missing 'ZDDC Browse'", stage)
}
})
}
// Non-canonical missing folder still 404s (the fallback is // Non-canonical missing folder still 404s (the fallback is
// scoped to the four canonical names, not a blanket "missing → // scoped to the four canonical names, not a blanket "missing →
// empty" rule). // empty" rule).