feat(zddc): defaults — browse hosts the markdown editor for working/+reviewing/

Flip default_tool from `mdedit` to `browse` (which now ships a Toast UI
markdown editor plugin in its preview pane) at:

  • paths."*".paths.working
  • paths."*".paths.working.paths."*"   (per-user homes)
  • paths."*".paths.reviewing

available_tools at those levels drops `mdedit` and adds `browse` next
to `classifier`. Operator overrides per .zddc cascade still work; only
the embedded baseline changes.

Test fixtures updated:

  • lookups_test.go     — DefaultToolAt assertions for working/+reviewing/
  • availability_test.go — AppAvailableAt + DefaultAppAt for working/+
                           reviewing/+per-user home
  • main_test.go        — dispatch route asserts "ZDDC Browse" (was "ZDDC
                          Markdown"); Apps cascade fixture swaps mdedit
                          for browse so the live route fetches the right
                          embedded HTML
This commit is contained in:
ZDDC 2026-05-13 10:34:06 -05:00
parent b5aab81d31
commit 7fbe7867fd
4 changed files with 56 additions and 55 deletions

View file

@ -138,14 +138,14 @@ func TestDispatchAppsResolution(t *testing.T) {
"archive": upstream.URL + "/archive_stable.html",
"transmittal": upstream.URL + "/transmittal_stable.html",
"classifier": upstream.URL + "/classifier_stable.html",
"mdedit": upstream.URL + "/mdedit_stable.html",
"landing": upstream.URL + "/landing_stable.html",
"browse": upstream.URL + "/browse_stable.html",
},
}
if err := zddc.WriteFile(root, zf); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Create folder convention dirs so classifier/mdedit/transmittal
// Create folder convention dirs so classifier/browse/transmittal
// availability rules pass for the test paths used below.
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
@ -380,10 +380,10 @@ func TestDispatchArchiveRedirect(t *testing.T) {
func TestDispatchSlashRouting(t *testing.T) {
// Convention: <dir>/ → browse (directory view, via DirTool which
// defaults to browse); <dir> → the directory's default_tool ("the
// specialized app": mdedit under working/, transmittal under
// staging/, archive under archive/, tables under archive/<party>/mdl).
// Without a default_tool, no-slash falls through to the trailing-
// slash redirect (302).
// specialized app": browse under working/+reviewing/, transmittal
// under staging/, archive under archive/, tables under
// archive/<party>/mdl). Without a default_tool, no-slash falls
// through to the trailing-slash redirect (302).
//
// The only trailing-slash redirect is for a directory that is the
// rows-dir of a table declared via a REAL on-disk parent .zddc
@ -429,7 +429,7 @@ func TestDispatchSlashRouting(t *testing.T) {
wantNoRedirect bool
wantLoc string // checked when wantStatus is a redirect
}{
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true, ""},
{"working no-slash → browse", "/Project/working", http.StatusOK, true, ""},
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
@ -525,21 +525,21 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
}
// No-trailing-slash form on a canonical folder → default app
// (mdedit for working/, transmittal for staging/, archive for
// archive/). Mirror of the existing "no-slash → default app"
// behavior at the IsDir branch, extended to cover the case where
// the folder doesn't exist on disk yet.
// (browse for working/+reviewing/, transmittal for staging/,
// archive for archive/). Mirror of the existing "no-slash →
// default app" behavior at the IsDir branch, extended to cover
// the case where the folder doesn't exist on disk yet.
noSlashDefaultApp := []struct {
stage string
expect string // substring that should appear in the response body
}{
{"working", "ZDDC Markdown"},
{"working", "ZDDC Browse"},
{"staging", "ZDDC Transmittal"},
{"archive", "ZDDC Archive"},
// reviewing/ also routes to mdedit; the polyfill follows the
// virtual aggregator's listing into canonical archive/+staging
// paths from there.
{"reviewing", "ZDDC Markdown"},
// reviewing/ also routes to browse (markdown editor lives
// inside it now); the polyfill follows the virtual aggregator's
// listing into canonical archive/+staging paths from there.
{"reviewing", "ZDDC Browse"},
}
for _, tc := range noSlashDefaultApp {
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {

View file

@ -35,11 +35,12 @@ func TestAppAvailableAt(t *testing.T) {
{root + "/Project-A/archive/ACME/mdl", "classifier", false},
{root + "/Project-A/some-other-folder", "classifier", false},
// mdedit: working/ only (review responses live in working/<rs-name>/)
{root + "/Project-A/working", "mdedit", true},
{root + "/Project-A/working/sub", "mdedit", true},
{root + "/Project-A/staging", "mdedit", false},
{root + "/Project-A/archive/ACME/incoming", "mdedit", false},
// browse: universal — every directory has browse available
// (it's in the embedded-defaults baseline available_tools).
{root + "/Project-A/working", "browse", true},
{root + "/Project-A/working/sub", "browse", true},
{root + "/Project-A/staging", "browse", true},
{root + "/Project-A/archive/ACME/incoming", "browse", true},
// transmittal: staging/ only
{root + "/Project-A/staging", "transmittal", true},
@ -48,8 +49,6 @@ func TestAppAvailableAt(t *testing.T) {
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
// case-fold: any case of canonical names matches
{root + "/Project-A/Working", "mdedit", true},
{root + "/Project-A/WORKING", "mdedit", true},
{root + "/Project-A/Staging", "transmittal", true},
{root + "/Project-A/STAGING", "transmittal", true},
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
@ -81,9 +80,9 @@ func TestDefaultAppAt(t *testing.T) {
// no-slash falls through to the redirect.
{root + "/Project-A", ""},
// Canonical project-root folders.
{root + "/Project-A/working", "mdedit"},
{root + "/Project-A/working/alice@example.com", "mdedit"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
{root + "/Project-A/working", "browse"},
{root + "/Project-A/working/alice@example.com", "browse"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"},
{root + "/Project-A/staging", "transmittal"},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
// archive: at the archive root, party folders default to archive.
@ -98,15 +97,16 @@ func TestDefaultAppAt(t *testing.T) {
// mdl wins over the broader archive rule.
{root + "/Project-A/archive/Acme/mdl", "tables"},
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
// reviewing/ is virtual but mdedit is wired as the default
// tool; the polyfill follows the listing's canonical URLs
// into archive/ and staging/ for the actual files.
{root + "/Project-A/reviewing", "mdedit"},
{root + "/Project-A/reviewing/123-EM-SUB-0001", "mdedit"},
// reviewing/ is virtual; browse hosts the markdown editor that
// renders responses (the polyfill follows the listing's
// canonical URLs into archive/ and staging/ for the actual
// files).
{root + "/Project-A/reviewing", "browse"},
{root + "/Project-A/reviewing/123-EM-SUB-0001", "browse"},
// Random non-canonical folder names → no default.
{root + "/Project-A/scratch", ""},
// Case-fold on canonical names.
{root + "/Project-A/Working", "mdedit"},
{root + "/Project-A/Working", "browse"},
{root + "/Project-A/STAGING", "transmittal"},
{root + "/Project-A/Archive/Acme/MDL", "tables"},
}

View file

@ -53,11 +53,12 @@ roles:
members: []
# Universal tool baseline. archive (record browser), browse (file
# tree), and landing (project picker) work everywhere. Each canonical
# folder below adds its own context-specific tools (mdedit in
# working/, transmittal in staging/, etc.). The cascade unions
# available_tools across all levels — leaf restrictions don't drop
# ancestor entries — so this baseline propagates to every descendant.
# tree, hosts the in-place markdown editor), and landing (project
# picker) work everywhere. Each canonical folder below adds its own
# context-specific tools (transmittal in staging/, etc.). The cascade
# unions available_tools across all levels — leaf restrictions don't
# drop ancestor entries — so this baseline propagates to every
# descendant.
available_tools: [archive, browse, landing]
# ── The slash / no-slash routing convention ────────────────────────────────
@ -71,9 +72,9 @@ available_tools: [archive, browse, landing]
# default; you rarely set it.
# <dir> (no slash) → `default_tool` — the "specialized
# app" for this folder (e.g. archive,
# transmittal, mdedit, tables). If a
# folder declares no default_tool, the
# no-slash form just 302s to the slash
# transmittal, tables). If a folder
# declares no default_tool, the no-
# slash form just 302s to the slash
# form, so you land on `dir_tool`.
#
# JSON listing requests are unaffected by either key — they always get
@ -81,7 +82,7 @@ available_tools: [archive, browse, landing]
# can enumerate entries no matter what dir_tool/default_tool are.
#
# Both keys cascade leaf→root: a parent's default_tool applies to
# descendants unless a deeper level overrides it (mdedit set on
# descendants unless a deeper level overrides it (browse set on
# working/ reaches working/alice/notes/ for free). The keys below set
# default_tool on the canonical folders; dir_tool is left unset
# everywhere, so the slash form is always `browse`.
@ -201,8 +202,8 @@ paths:
default_tool: archive
worm: [document_controller]
working:
default_tool: mdedit
available_tools: [mdedit, classifier]
default_tool: browse
available_tools: [browse, classifier]
# working/ auto-owns the first creator + the per-user homes
# below.
auto_own: true
@ -215,8 +216,8 @@ paths:
admins: [document_controller]
paths:
"*": # per-user home dir
default_tool: mdedit
available_tools: [mdedit, classifier]
default_tool: browse
available_tools: [browse, classifier]
auto_own: true
# Per-user home is private by default: the generated
# auto-own .zddc carries inherit:false so ancestor ACL
@ -233,8 +234,8 @@ paths:
# rationale as working/.
admins: [document_controller]
reviewing:
default_tool: mdedit
available_tools: [mdedit]
default_tool: browse
available_tools: [browse]
# reviewing/ is purely virtual — the aggregator handler
# synthesises listings from received/ ↔ staging/ ↔ issued/.
virtual: true

View file

@ -21,10 +21,10 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
{filepath.Join(root, "Project-X", "working"), "mdedit"},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "mdedit"},
{filepath.Join(root, "Project-X", "working"), "browse"},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "browse"},
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
{filepath.Join(root, "Project-X", "reviewing"), "mdedit"},
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
}
for _, tc := range cases {
got := DefaultToolAt(root, tc.path)
@ -177,7 +177,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
t.Fatal(err)
}
// Operator declares that Special/working uses classifier
// instead of the embedded-default mdedit.
// instead of the embedded-default browse.
writeZddc(t, filepath.Join(root, "Special", "working"),
"default_tool: classifier\n")
@ -185,7 +185,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
t.Errorf("operator override should set default_tool=classifier, got %q", got)
}
// Default still applies at other projects.
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "mdedit" {
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "browse" {
t.Errorf("default convention should hold at unchanged paths, got %q", got)
}
}
@ -193,14 +193,14 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets
// default_tool, descendants inherit it unless they override. So a
// path under working/ that isn't explicitly declared in paths: still
// gets mdedit as its default tool.
// gets browse as its default tool.
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
resetCache()
root := t.TempDir()
// Deep path under working/ — not explicitly mentioned in paths:.
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep")
if got := DefaultToolAt(root, deep); got != "mdedit" {
t.Errorf("DefaultToolAt(%q) = %q, want mdedit (cascade propagation)",
if got := DefaultToolAt(root, deep); got != "browse" {
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
deep[len(root):], got)
}
}