From d052e9fed3efb0c802281e3c9be94ddd83d09ce6 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 11 May 2026 12:30:34 -0500 Subject: [PATCH] Round of UX fixes: tool strip removed, MDL routing, browse markdown layout, reviewing depth-2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four user-reported items: 1. landing: remove the standalone-tool strip from the site picker. Per user, it was awkward — links pointing at zddc.varasys.io releases from inside a deployment is a layering confusion. The nav.tool-strip block in landing/template.html and its CSS are gone. 2. zddc-server: route /Project/archive//mdl[/] to the tables app for the virtual-MDL case where the on-disk folder doesn't exist yet. Previously fell through to 404 because the dispatcher only routed virtual mdl/ via the IsDir branch — the IsNotExist branch was missing the equivalent check. Now both shapes (with and without trailing slash) hit RecognizeTableRequest's default- MDL fallback and ServeTable serves the embedded tables.html. 3. browse: re-layout the markdown editor to mirror mdedit's layout. Was: sidebar on right with TOC top + front-matter bottom. Now: sidebar on LEFT with YAML front matter top + Outline bottom, content on RIGHT with an informational header (file title + save controls + status + source) above the Toast UI editor. New horizontal resizer between the front-matter and outline sections inside the sidebar (drag the row boundary; arrow keys step by 24 px). Browse test selectors updated. 4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the user can preview files inside virtual reviewing// received/ and staged/ folders. IsReviewingPath now returns a sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's depth-2 branch proxies the underlying real folder's listing, emitting folder entries with virtual reviewing/ URLs (so navigation stays in the aggregator) and file entries with canonical archive/ or staging/ URLs (so byte fetches resolve directly). ACL is enforced against the real path; depth-1 received/ + staged/ URLs are now virtual too (was canonical), so the user smoothly descends into the depth-2 listing. Tests updated for the new IsReviewingPath signature and the depth-1 URL shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/css/tree.css | 171 ++++++++------ browse/js/preview-markdown.js | 209 +++++++++++------ landing/css/landing.css | 45 ---- landing/template.html | 42 ---- tests/browse.spec.js | 7 +- zddc/cmd/zddc-server/main.go | 25 ++- zddc/internal/handler/reviewinghandler.go | 212 ++++++++++++++---- .../internal/handler/reviewinghandler_test.go | 56 +++-- zddc/internal/handler/tables.html | 2 +- 9 files changed, 474 insertions(+), 295 deletions(-) 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 {
ZDDC Table - v0.0.17-alpha · 2026-05-11 17:07:33 · c87fb7f-dirty + v0.0.17-alpha · 2026-05-11 17:29:36 · b1479c5-dirty