diff --git a/browse/css/tree.css b/browse/css/tree.css
index 46a29f7..b044db0 100644
--- a/browse/css/tree.css
+++ b/browse/css/tree.css
@@ -360,80 +360,43 @@ html, body {
.status-bar.is-info { color: var(--text); }
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
-/* CSS-Grid shell. Two columns (editor | sidebar) and two rows (toolbar
- | body). The grid gives every cell a definite size, which Toast UI
- needs to compute its scroll regions correctly. A 4-px resizer sits
- between the editor and sidebar; JS updates grid-template-columns on
- drag. */
+/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT
+ (front matter top + TOC bottom), content on the RIGHT (informational
+ header above the Toast UI editor). The grid gives every cell a
+ definite size, which Toast UI needs to compute its scroll regions
+ correctly. */
.md-shell {
display: grid;
- grid-template-rows: auto 1fr;
- grid-template-columns: 1fr 260px; /* JS overrides on resize */
- grid-template-areas:
- "toolbar toolbar"
- "editor sidebar";
+ grid-template-rows: 1fr;
+ grid-template-columns: 280px 1fr; /* JS overrides on resize */
+ grid-template-areas: "sidebar content";
height: 100%;
min-height: 0;
background: var(--bg);
overflow: hidden;
}
-/* Toolbar spans both columns; subtle row above the editor. */
-.md-shell__toolbar {
- grid-area: toolbar;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.35rem 0.75rem;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
- font-size: 0.85rem;
-}
-.md-shell__dirty {
- color: var(--text-muted);
- font-size: 0.85rem;
- min-width: 5.5rem;
-}
-.md-shell__status {
- flex: 1;
- text-align: right;
- color: var(--text-muted);
- font-size: 0.85rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.md-shell__source {
- color: var(--text-muted);
- font-size: 0.75rem;
- font-style: italic;
- margin-left: 0.5rem;
- padding: 0.15rem 0.4rem;
- border-radius: var(--radius);
- background: var(--bg);
- border: 1px solid var(--border);
-}
-
-/* Editor host: a single grid cell with overflow:hidden so Toast UI's
- internal scrollers handle the content. */
-.md-shell__editor {
- grid-area: editor;
- min-width: 0;
+/* Sidebar (col 1): two stacked sections — Front matter (top, fixed
+ default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
+.md-shell__sidebar {
+ grid-area: sidebar;
+ display: grid;
+ grid-template-rows: 180px 1fr; /* JS overrides on resize */
min-height: 0;
overflow: hidden;
- /* Toast UI mounts a .toastui-editor-defaultUI element here; give
- it a definite height via height:100% in the JS. */
+ border-right: 1px solid var(--border);
+ background: var(--bg);
+ position: relative;
}
-/* Resizer sits on the grid border between editor (col 1) and sidebar
- (col 2). Positioned absolutely over the boundary so it doesn't take
- up a grid track itself. */
+/* Vertical sidebar/content resizer. Sits absolutely on the column
+ boundary so it doesn't occupy a grid track. */
.md-shell__resizer {
- grid-area: editor;
+ grid-area: sidebar;
align-self: stretch;
justify-self: end;
width: 6px;
- margin-right: -3px; /* center on the column boundary */
+ margin-right: -3px;
cursor: col-resize;
background: transparent;
z-index: 2;
@@ -446,17 +409,91 @@ html, body {
outline: none;
}
-/* Sidebar (right column): grid of two stacked sections — Outline
- (1fr) takes the bulk of the height, Front matter (auto, capped) is
- below. */
-.md-shell__sidebar {
- grid-area: sidebar;
+/* Horizontal resizer between front-matter and TOC inside the sidebar.
+ Spans both rows by placement, then absolutely positioned to overlay
+ the grid-row boundary. */
+.md-shell__fmresizer {
+ grid-column: 1;
+ grid-row: 1;
+ align-self: end;
+ justify-self: stretch;
+ height: 6px;
+ margin-bottom: -3px;
+ cursor: row-resize;
+ background: transparent;
+ z-index: 2;
+ transition: background 0.12s;
+}
+.md-shell__fmresizer:hover,
+.md-shell__fmresizer.is-dragging,
+.md-shell__fmresizer:focus-visible {
+ background: var(--primary);
+ outline: none;
+}
+
+/* Content (col 2): informational header above the Toast UI editor. */
+.md-shell__content {
+ grid-area: content;
display: grid;
- grid-template-rows: 1fr auto;
+ grid-template-rows: auto 1fr;
+ min-width: 0;
min-height: 0;
overflow: hidden;
- border-left: 1px solid var(--border);
+}
+
+/* Informational header above the editor: file name on the left, then
+ dirty marker, status, source hint, save button. Reads as a header
+ for the content panel — file metadata at a glance. */
+.md-shell__infohdr {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.75rem;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.85rem;
+}
+.md-shell__title {
+ flex: 1;
+ font-family: var(--font-display);
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+.md-shell__dirty {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ min-width: 5.5rem;
+ text-align: right;
+}
+.md-shell__status {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 14rem;
+}
+.md-shell__source {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ font-style: italic;
+ padding: 0.15rem 0.4rem;
+ border-radius: var(--radius);
background: var(--bg);
+ border: 1px solid var(--border);
+}
+
+/* Editor host: a single grid cell with overflow:hidden so Toast UI's
+ internal scrollers handle the content. */
+.md-shell__editor {
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
}
.md-side {
@@ -465,10 +502,8 @@ html, body {
min-height: 0;
overflow: hidden;
}
-.md-side--fm {
+.md-side--toc {
border-top: 1px solid var(--border);
- /* Front matter doesn't dominate — cap it so the outline keeps room. */
- max-height: 40%;
}
.md-side__header {
padding: 0.35rem 0.75rem;
diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js
index b20f073..dcfd0c5 100644
--- a/browse/js/preview-markdown.js
+++ b/browse/js/preview-markdown.js
@@ -28,9 +28,10 @@
if (!window.app || !window.app.modules) return;
- var TOC_MIN_WIDTH = 180;
- var TOC_MAX_WIDTH = 480;
- var TOC_DEFAULT_WIDTH = 260;
+ var SIDEBAR_MIN_WIDTH = 180;
+ var SIDEBAR_MAX_WIDTH = 480;
+ var SIDEBAR_DEFAULT_WIDTH = 280;
+ var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
function escapeHtml(s) {
return String(s).replace(/&/g, '&').replace(/
-
-
-
Welcome to the ZDDC Archive
diff --git a/tests/browse.spec.js b/tests/browse.spec.js
index c3fda3a..2da725a 100644
--- a/tests/browse.spec.js
+++ b/tests/browse.spec.js
@@ -90,11 +90,12 @@ test.describe('Browse', () => {
await page.waitForSelector('#treeBody .tree-row[data-isdir="false"]', { timeout: 10000 });
await page.locator('#treeBody .tree-row[data-isdir="false"]').first().click();
- // Markdown plugin DOM mounts: shell, toolbar, editor host, sidebar.
+ // Markdown plugin DOM mounts: shell, sidebar (front matter +
+ // TOC), content (info header + editor).
await expect(page.locator('.md-shell')).toBeVisible({ timeout: 15000 });
- await expect(page.locator('.md-shell__toolbar')).toBeVisible();
- await expect(page.locator('.md-shell__editor')).toBeVisible();
await expect(page.locator('.md-shell__sidebar')).toBeVisible();
+ await expect(page.locator('.md-shell__infohdr')).toBeVisible();
+ await expect(page.locator('.md-shell__editor')).toBeVisible();
// Outline lists the three headings.
await page.waitForSelector('.md-toc__list .md-toc__item', { timeout: 10000 });
diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go
index e7b60c9..8f2669c 100644
--- a/zddc/cmd/zddc-server/main.go
+++ b/zddc/cmd/zddc-server/main.go
@@ -853,7 +853,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// 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 proj, tracking, ok := handler.IsReviewingPath(urlPath); ok {
+ if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
if !strings.HasSuffix(urlPath, "/") {
if tracking != "" {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
@@ -866,13 +866,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
- handler.ServeReviewing(cfg, w, r, proj, tracking)
+ handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath)
return
}
// HTML trailing-slash falls through to canonical-folder
// block → ServeDirectory → embedded browse.html.
}
}
+ // Default-MDL virtual directory at archive//mdl[/].
+ // The rows-dir doesn't have to exist on disk —
+ // RecognizeTableRequest's default-MDL fallback handles a
+ // fully-missing path so a fresh party with no entries yet
+ // still lands on a usable table view (rather than 404).
+ // Both slash and no-slash forms serve the tables app
+ // directly; the slash form is the canonical URL the MDL
+ // card on the project landing page links to.
+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
+ base := strings.TrimSuffix(urlPath, "/")
+ synth := base + "/table.html"
+ if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
+ chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
+ if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ handler.ServeTable(cfg, tr, w, r)
+ return
+ }
+ }
// Canonical project-root folder fallback. /{archive,
// working,staging,reviewing}[/] should land on a usable view
// (default tool or empty listing) rather than 404, so the
diff --git a/zddc/internal/handler/reviewinghandler.go b/zddc/internal/handler/reviewinghandler.go
index 2b914bb..619e992 100644
--- a/zddc/internal/handler/reviewinghandler.go
+++ b/zddc/internal/handler/reviewinghandler.go
@@ -18,30 +18,48 @@ import (
)
// IsReviewingPath classifies a URL as a reviewing-aggregator path and
-// extracts (project, tracking). The aggregator is a virtual view at:
+// extracts (project, tracking, sidePath). The aggregator is a virtual
+// view at:
//
-// /reviewing/ → depth 0: list pending submittals
-// /reviewing// → depth 1: list received/ + staged/
+// /reviewing/ → depth 0: pending submittals
+// /reviewing// → depth 1: received/ + staged/
+// /reviewing///[...] → depth ≥ 2: real folder
+// contents (received or
+// staged), proxied from
+// the canonical archive
+// or staging path so the
+// user can preview files
+// in the browse pane
+// without leaving the
+// reviewing view.
//
-// Anything deeper than depth 1 returns ok=false; the depth-1 listing
-// emits canonical URLs (under archive/ and staging/) so navigation past
-// that point goes through the regular file-tree handlers, not back into
-// the virtual reviewing/ subtree.
+// sidePath at depth 1 is "" (no side selected yet). At depth ≥ 2 it's
+// "received[/rest...]" or "staged[/rest...]" — the slash-separated
+// remainder after the tracking segment.
//
-// Trailing slash on either depth is required and tolerated. Match on
-// "reviewing" is case-insensitive.
-func IsReviewingPath(urlPath string) (project, tracking string, ok bool) {
+// Match on "reviewing" is case-insensitive.
+func IsReviewingPath(urlPath string) (project, tracking, sidePath string, ok bool) {
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") {
- return "", "", false
+ return "", "", "", false
}
switch len(parts) {
case 2:
- return parts[0], "", true
+ return parts[0], "", "", true
case 3:
- return parts[0], parts[2], true
+ return parts[0], parts[2], "", true
default:
- return "", "", false
+ // parts[3] is the side; remainder joins back as the sub-path
+ // within the real folder.
+ side := strings.ToLower(parts[3])
+ if side != "received" && side != "staged" {
+ return "", "", "", false
+ }
+ rest := strings.Join(parts[4:], "/")
+ if rest == "" {
+ return parts[0], parts[2], side, true
+ }
+ return parts[0], parts[2], side + "/" + rest, true
}
}
@@ -213,13 +231,27 @@ func computePending(ctx context.Context, decider policy.Decider,
return result, nil
}
-// ServeReviewing emits the aggregator JSON listing for either depth 0
-// (project's full pending list) or depth 1 (one submittal's
-// received/ + staged/ pair). The HTML branch is handled separately by
-// the apps subsystem (mdedit served at the URL); only requests that
-// accept JSON reach here.
+// ServeReviewing emits the aggregator JSON listing for any depth under
+// /reviewing/. The HTML branch is handled separately by the
+// apps subsystem (mdedit served at the URL); only requests that accept
+// JSON reach here.
+//
+// Depths:
+//
+// 0 (tracking="") → list pending submittals as virtual
+// / folders.
+// 1 (tracking, side="") → list received/ + staged/ virtual folders.
+// ≥2 (tracking, sidePath) → proxy the listing of the real folder
+// under archive//received//...
+// or staging//... so the user can
+// preview files without leaving the
+// reviewing view. Folder entries keep
+// virtual reviewing/ URLs (navigation
+// stays in the aggregator). File entries
+// use canonical URLs so byte fetches
+// resolve directly against the real path.
func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
- project, tracking string) {
+ project, tracking, sidePath string) {
pending, err := computePending(r.Context(), DeciderFromContext(r),
cfg.Root, project, EmailFromContext(r))
@@ -229,11 +261,9 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
}
var entries []listing.FileInfo
- switch tracking {
- case "":
+ switch {
+ case tracking == "":
// Depth 0: list pending submittals as virtual / folders.
- // The URLs stay under reviewing/ so the user can drill into a
- // per-submittal view.
urlPrefix := "/" + project + "/reviewing/"
for _, s := range pending {
entries = append(entries, listing.FileInfo{
@@ -245,10 +275,7 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
})
}
default:
- // Depth 1: find the matching pending entry; emit received/ +
- // staged/ pointing at canonical archive/staging URLs. Clients
- // using the polyfill follow these URLs out of the virtual
- // subtree into the real file paths underneath.
+ // Depth ≥1: find the pending entry for this tracking number.
var match *pendingSubmittal
for i := range pending {
if pending[i].tracking == tracking {
@@ -260,21 +287,130 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
http.Error(w, "Not Found", http.StatusNotFound)
return
}
- entries = append(entries, listing.FileInfo{
- Name: "received/",
- URL: match.receivedURL,
- ModTime: match.lastModified,
- IsDir: true,
- Virtual: true,
- })
- if match.stagedURL != "" {
+ if sidePath == "" {
+ // Depth 1: emit received/ + staged/ virtual folder pointers.
+ // URLs stay under reviewing/ so navigation into them remains
+ // in the aggregator (handled by the depth ≥2 branch).
+ urlPrefix := "/" + project + "/reviewing/" + url.PathEscape(tracking) + "/"
entries = append(entries, listing.FileInfo{
- Name: "staged/",
- URL: match.stagedURL,
+ Name: "received/",
+ URL: urlPrefix + "received/",
ModTime: match.lastModified,
IsDir: true,
Virtual: true,
})
+ if match.stagedURL != "" {
+ entries = append(entries, listing.FileInfo{
+ Name: "staged/",
+ URL: urlPrefix + "staged/",
+ ModTime: match.lastModified,
+ IsDir: true,
+ Virtual: true,
+ })
+ }
+ } else {
+ // Depth ≥2: proxy the real folder's listing. sidePath is
+ // "received[/rest]" or "staged[/rest]" — split off the
+ // leading side, append remainder to the canonical base.
+ side := sidePath
+ rest := ""
+ if i := strings.IndexByte(sidePath, '/'); i >= 0 {
+ side, rest = sidePath[:i], sidePath[i+1:]
+ }
+ var realURL string
+ switch side {
+ case "received":
+ realURL = match.receivedURL
+ case "staged":
+ if match.stagedURL == "" {
+ http.Error(w, "Not Found", http.StatusNotFound)
+ return
+ }
+ realURL = match.stagedURL
+ default:
+ http.Error(w, "Not Found", http.StatusNotFound)
+ return
+ }
+ if rest != "" {
+ realURL = strings.TrimSuffix(realURL, "/") + "/" + rest + "/"
+ }
+ // Translate the real URL back to a filesystem path so we
+ // can list it. The URL still encodes percent-escapes;
+ // PathUnescape them before joining.
+ realRel := strings.TrimPrefix(realURL, "/")
+ realRel = strings.TrimSuffix(realRel, "/")
+ realRelDecoded, decodeErr := url.PathUnescape(realRel)
+ if decodeErr != nil {
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ realAbs := filepath.Join(cfg.Root, filepath.FromSlash(realRelDecoded))
+ if !strings.HasPrefix(realAbs, cfg.Root+string(filepath.Separator)) {
+ http.Error(w, "Not Found", http.StatusNotFound)
+ return
+ }
+ // ACL on the underlying real path; do not proxy what the
+ // caller can't read directly.
+ chain, err := zddc.EffectivePolicy(cfg.Root, realAbs)
+ if err == nil {
+ if allowed, _ := policy.AllowFromChain(r.Context(),
+ DeciderFromContext(r), chain,
+ EmailFromContext(r), realURL); !allowed {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+ }
+ diskEntries, err := os.ReadDir(realAbs)
+ if err != nil {
+ if os.IsNotExist(err) {
+ http.Error(w, "Not Found", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ // Build the virtual URL prefix (for folder entries) and
+ // the canonical URL prefix (for file entries).
+ virtualPrefix := "/" + project + "/reviewing/" +
+ url.PathEscape(tracking) + "/" + side + "/"
+ if rest != "" {
+ virtualPrefix += rest + "/"
+ }
+ canonicalPrefix := realURL // already ends with "/"
+ for _, e := range diskEntries {
+ name := e.Name()
+ if strings.HasPrefix(name, ".") {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ fi := listing.FileInfo{
+ Name: name,
+ ModTime: info.ModTime(),
+ }
+ if e.IsDir() {
+ fi.Name += "/"
+ fi.IsDir = true
+ fi.URL = virtualPrefix + url.PathEscape(name) + "/"
+ fi.Virtual = true
+ } else {
+ fi.Size = info.Size()
+ // File URL points at the canonical real path so
+ // fetches (preview, download) hit the right bytes
+ // directly — no proxying through the aggregator.
+ fi.URL = canonicalPrefix + url.PathEscape(name)
+ }
+ entries = append(entries, fi)
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ // Folders first, then files; both alphabetical.
+ if entries[i].IsDir != entries[j].IsDir {
+ return entries[i].IsDir
+ }
+ return entries[i].Name < entries[j].Name
+ })
}
}
diff --git a/zddc/internal/handler/reviewinghandler_test.go b/zddc/internal/handler/reviewinghandler_test.go
index 621f95c..95e1e38 100644
--- a/zddc/internal/handler/reviewinghandler_test.go
+++ b/zddc/internal/handler/reviewinghandler_test.go
@@ -18,26 +18,32 @@ func TestIsReviewingPath(t *testing.T) {
wantOK bool
wantProj string
wantTracking string
+ wantSide string
}{
- {"/Project/reviewing/", true, "Project", ""},
- {"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001"},
+ {"/Project/reviewing/", true, "Project", "", ""},
+ {"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001", ""},
// Case-insensitive on the literal "reviewing" segment.
- {"/Project/Reviewing/", true, "Project", ""},
- {"/Project/REVIEWING/x/", true, "Project", "x"},
+ {"/Project/Reviewing/", true, "Project", "", ""},
+ {"/Project/REVIEWING/x/", true, "Project", "x", ""},
// No trailing slash: still classified (caller decides redirect).
- {"/Project/reviewing", true, "Project", ""},
- {"/Project/reviewing/123/", true, "Project", "123"},
+ {"/Project/reviewing", true, "Project", "", ""},
+ {"/Project/reviewing/123/", true, "Project", "123", ""},
+ // Depth 2+: side present.
+ {"/Project/reviewing/123/received/", true, "Project", "123", "received"},
+ {"/Project/reviewing/123/staged/", true, "Project", "123", "staged"},
+ {"/Project/reviewing/123/received/sub/", true, "Project", "123", "received/sub"},
+ // Unknown side at depth 2 is rejected.
+ {"/Project/reviewing/123/issued/", false, "", "", ""},
// Non-canonical / wrong shape.
- {"/Project/", false, "", ""},
- {"/", false, "", ""},
- {"/Project/working/", false, "", ""},
- {"/Project/reviewing/x/y/", false, "", ""}, // depth >3 not supported
+ {"/Project/", false, "", "", ""},
+ {"/", false, "", "", ""},
+ {"/Project/working/", false, "", "", ""},
}
for _, tc := range cases {
- gotProj, gotTracking, gotOK := IsReviewingPath(tc.path)
- if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking {
- t.Errorf("IsReviewingPath(%q) = (%q,%q,%v), want (%q,%q,%v)",
- tc.path, gotProj, gotTracking, gotOK, tc.wantProj, tc.wantTracking, tc.wantOK)
+ gotProj, gotTracking, gotSide, gotOK := IsReviewingPath(tc.path)
+ if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking || gotSide != tc.wantSide {
+ t.Errorf("IsReviewingPath(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)",
+ tc.path, gotProj, gotTracking, gotSide, gotOK, tc.wantProj, tc.wantTracking, tc.wantSide, tc.wantOK)
}
}
}
@@ -85,7 +91,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
- ServeReviewing(cfg, rec, req, "Project", "")
+ ServeReviewing(cfg, rec, req, "Project", "", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
@@ -122,7 +128,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
- ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007")
+ ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
@@ -138,15 +144,17 @@ func TestServeReviewing(t *testing.T) {
if got[0].Name != "received/" {
t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/")
}
- // Canonical URL — outside reviewing/ subtree.
- if want := "/Project/archive/Beta/received/"; !startsWith(got[0].URL, want) {
- t.Errorf("received URL=%q, want prefix %q", got[0].URL, want)
+ // Virtual URL — stays under reviewing/ so depth-2 navigation
+ // returns to the aggregator (which lists the real folder's
+ // contents with canonical file URLs).
+ if want := "/Project/reviewing/002-AB-SUB-0007/received/"; got[0].URL != want {
+ t.Errorf("received URL=%q, want %q", got[0].URL, want)
}
if got[1].Name != "staged/" {
t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/")
}
- if want := "/Project/staging/"; !startsWith(got[1].URL, want) {
- t.Errorf("staged URL=%q, want prefix %q", got[1].URL, want)
+ if want := "/Project/reviewing/002-AB-SUB-0007/staged/"; got[1].URL != want {
+ t.Errorf("staged URL=%q, want %q", got[1].URL, want)
}
})
@@ -155,7 +163,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
- ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001")
+ ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
@@ -174,7 +182,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
- ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999")
+ ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999", "")
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
@@ -193,7 +201,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
- ServeReviewing(bareCfg, rec, req, "Fresh", "")
+ ServeReviewing(bareCfg, rec, req, "Fresh", "", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
}
diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html
index b2ae7e3..6be8403 100644
--- a/zddc/internal/handler/tables.html
+++ b/zddc/internal/handler/tables.html
@@ -1300,7 +1300,7 @@ body.help-open .app-header {