diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 2d9f697..51cdeb0 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -80,9 +80,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, result := make([]listing.FileInfo, 0, len(entries)) // Display overrides for this directory's children, sourced from - // THIS directory's .zddc `display:` map. Built once and looked up - // case-insensitively per entry. Empty map = no overrides. - displayMap := readDisplayMap(absDir) + // THIS directory's cascade-resolved `display:` map (embedded defaults + + // ancestor + on-disk overrides). Built once and looked up case- + // insensitively per entry. Empty/nil = no overrides. Cascade-resolved + // (not just the on-disk file) so the baked-in default labels — archive → + // "Archive", mdl → "Master Deliverables List", … — render with no + // per-project config, while an operator's on-disk display: still wins. + displayMap := zddc.DisplayAt(fsRoot, absDir) // Set of cascade-declared child names (lowercase) for this dir. // Entries with a matching name get Declared=true so clients can @@ -396,25 +400,6 @@ func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot return synth } -// readDisplayMap parses dirAbs/.zddc and returns its Display map (or -// nil when the file doesn't exist or has no display block). All keys -// are case-folded to lowercase so lookupDisplay's case-insensitive -// match is a simple map read. -func readDisplayMap(dirAbs string) map[string]string { - zf, err := zddc.ParseFile(filepath.Join(dirAbs, ".zddc")) - if err != nil || len(zf.Display) == 0 { - return nil - } - out := make(map[string]string, len(zf.Display)) - for k, v := range zf.Display { - if v == "" { - continue - } - out[strings.ToLower(strings.TrimSpace(k))] = v - } - return out -} - // lookupDisplay returns the custom display label for name (matched // case-insensitively against displayMap's keys), or "" when no // override applies. diff --git a/zddc/internal/zddc/defaults/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/.zddc index 44ee314..6a50e3b 100644 --- a/zddc/internal/zddc/defaults/_any_/.zddc +++ b/zddc/internal/zddc/defaults/_any_/.zddc @@ -5,3 +5,17 @@ acl: project_team: r observer: r document_controller: rw + +# Friendly labels for the canonical project peers. On-disk names stay +# simple/lowercase; clients render these (listing display_name) in their +# place. Cascade-merged + per-key overridable, so an operator can rename +# any label in an on-disk project .zddc without renaming the folder. +display: + archive: Archive + incoming: Incoming + working: Working + staging: Staging + reviewing: Reviewing + mdl: Master Deliverables List + rsk: Risk Register + ssr: "Supplier/Subcontractor Status Report" diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 0d7dc39..77746d9 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -31,6 +31,40 @@ func DefaultToolAt(fsRoot, dirPath string) string { return chain.Embedded.DefaultTool } +// DisplayAt returns the cascade-resolved `display:` map for a directory — +// the human-friendly labels a client renders in place of on-disk child +// names (e.g. mdl → "Master Deliverables List"). Unlike a single tool +// name, display: is a MAP that merges across the cascade: the embedded +// baseline is the floor, then each on-disk level overrides per key +// (shallow→deep, so the deepest .zddc wins). Keys are lower-cased so the +// caller's lookup is case-insensitive on the on-disk basename. Returns nil +// when nothing declares a label. Mirrors how walker.go merges Display, but +// resolved on demand for one path (the listing reads it per directory). +func DisplayAt(fsRoot, dirPath string) map[string]string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return nil + } + merged := map[string]string{} + add := func(m map[string]string) { + for k, v := range m { + v = strings.TrimSpace(v) + if v == "" { + continue + } + merged[strings.ToLower(strings.TrimSpace(k))] = v + } + } + add(chain.Embedded.Display) // embedded baseline (the defaults tree) + for i := 0; i < len(chain.Levels); i++ { + add(chain.Levels[i].Display) // on-disk overrides, shallow→deep + } + if len(merged) == 0 { + return nil + } + return merged +} + // DirToolAt returns the cascade-resolved tool name served at the // directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root // (then the embedded defaults), returning the first non-empty