From 3fc371752aafd58391882ba024defc2b1a4c6e2a Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 20:34:53 -0500 Subject: [PATCH] feat(zddc-server): empty listing for canonical project folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listing /{archive,working,staging,reviewing}/ when the folder doesn't exist on disk now returns an empty 200 listing instead of 404. The stage-strip nav links into these folders unconditionally; without this fallback, clicking "Working" against a fresh project (where working/ hasn't been written to yet) lands on a 404 page rather than a usable empty view. Mechanism stays consistent with the existing lazy-folder design: - GET on missing canonical folder → 200 + empty listing (this commit) - first WRITE under the same path → EnsureCanonicalAncestors materialises the on-disk folder + auto-own .zddc reviewing/ stays virtual-only (in VirtualOnlyCanonicalNames); the fallback just makes its empty listing always renderable. The future reviewing/ aggregator (recorded in project memory) will replace the empty listing with the join-computed virtual entries. The fallback is gated on IsProjectRootFolder — only depth-2 paths matching one of the four canonical names. Non-canonical missing paths still 404 (TestListDirectory_NonCanonicalMissing_StillNotFound). For working/ specifically the synthetic / home entry still fires from virtualUserHomeEntry, so the user sees their own placeholder even when working/ doesn't exist yet — first write into that placeholder triggers the lazy-create chain. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/fs/tree.go | 15 +++++++- zddc/internal/fs/tree_test.go | 58 ++++++++++++++++++++++++++++++ zddc/internal/zddc/special.go | 32 +++++++++++++++++ zddc/internal/zddc/special_test.go | 31 ++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index e275606..c658e03 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -47,7 +47,20 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, entries, err := os.ReadDir(absDir) if err != nil { - return nil, err + // Empty-listing fallback for canonical project folders. A fresh + // project doesn't have working/, staging/, reviewing/, or even + // archive/ on disk until something is written into them + // (EnsureCanonicalAncestors materialises lazily). The stage-strip + // nav links into these folders unconditionally; without this + // fallback, a click on "Working" against a fresh project 404s. + // Returning [] makes the click land on a usable empty view; the + // virtualUserHomeEntry below still fires for working/ so the + // user sees their own home placeholder. + if os.IsNotExist(err) && zddc.IsProjectRootFolder(dirPath) { + entries = nil + } else { + return nil, err + } } var result []listing.FileInfo diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index 854f197..57a7f54 100644 --- a/zddc/internal/fs/tree_test.go +++ b/zddc/internal/fs/tree_test.go @@ -146,3 +146,61 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) { t.Errorf("PascalCase Working/ should still surface the synthetic entry; got entries: %+v", got) } } + +// Listing a canonical project folder that doesn't exist on disk yet +// returns an empty slice instead of os.ErrNotExist. The stage-strip +// nav links into /working/ etc. unconditionally; this keeps +// fresh projects (no working/ on disk yet) from 404'ing. +func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) { + root := setupTreeRoot(t) + // Proj exists but Proj/working/ does NOT. + if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"), + []byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + + for _, stage := range []string{"working", "staging", "reviewing", "archive"} { + got, err := ListDirectory(context.Background(), nil, root, + "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/") + if err != nil { + t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err) + continue + } + // working/ surfaces a synthetic / entry; the others + // should be a flat empty listing. + if stage == "working" { + if len(got) != 1 || !got[0].Virtual { + t.Errorf("ListDirectory(Proj/working) on missing dir: got %+v, want only the virtual home entry", got) + } + } else { + if len(got) != 0 { + t.Errorf("ListDirectory(Proj/%s) on missing dir: got %+v, want empty", stage, got) + } + } + } +} + +// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback +// only applies to the four canonical project-root folders. +func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) { + root := setupTreeRoot(t) + if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"), + []byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + + _, err := ListDirectory(context.Background(), nil, root, + "Proj/random-folder-that-doesnt-exist", "alice@example.com", + "/Proj/random-folder-that-doesnt-exist/") + if !os.IsNotExist(err) { + t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err) + } +} diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index 671e0aa..92010fb 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -49,6 +49,38 @@ var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"} // MkdirAll for it. var VirtualOnlyCanonicalNames = []string{"reviewing"} +// IsProjectRootFolder reports whether dirPath (relative to fsRoot, +// forward-slash-separated, no leading slash) names one of the canonical +// project-root folders at exactly depth 2: /. +// Match is case-insensitive against ProjectRootFolders. +// +// Used by the directory listing endpoint to materialise an empty +// listing for canonical folders that don't yet exist on disk, so a +// fresh project's nav links never land on 404. The first write under +// such a path triggers EnsureCanonicalAncestors which lazily creates +// the real on-disk folder + auto-own .zddc. +// +// Trailing slashes and accidental "./" segments are tolerated. Paths +// of any other depth (e.g. project root itself, or deeper subpaths +// like working//) return false — the fallback only applies at +// the canonical-folder boundary. +func IsProjectRootFolder(dirPath string) bool { + clean := strings.Trim(filepath.ToSlash(dirPath), "/") + if clean == "" { + return false + } + parts := strings.Split(clean, "/") + if len(parts) != 2 { + return false + } + for _, name := range ProjectRootFolders { + if strings.EqualFold(parts[1], name) { + return true + } + } + return false +} + // WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting // principalEmail rwcda and recording it in CreatedBy. Used by the file // API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed diff --git a/zddc/internal/zddc/special_test.go b/zddc/internal/zddc/special_test.go index db3c21e..5ff0f6f 100644 --- a/zddc/internal/zddc/special_test.go +++ b/zddc/internal/zddc/special_test.go @@ -202,3 +202,34 @@ func TestCanonicalLists(t *testing.T) { t.Errorf("VirtualOnlyCanonicalNames = %v, missing entries", VirtualOnlyCanonicalNames) } } + +func TestIsProjectRootFolder(t *testing.T) { + cases := map[string]bool{ + // Canonical positions, lowercase. + "Proj/archive": true, + "Proj/working": true, + "Proj/staging": true, + "Proj/reviewing": true, + // Case-fold. + "Proj/Working": true, + "Proj/STAGING": true, + "Proj/Reviewing": true, + // Trailing slash tolerated (handler trims but be defensive). + "Proj/working/": true, + // Non-canonical second segment. + "Proj/random": false, + "Proj/archive2": false, + // Wrong depth — root, single segment, or deeper. + "": false, + "Proj": false, + "Proj/working/casey": false, + "Proj/archive/ACME": false, + "Proj/archive/ACME/issued": false, + } + for path, want := range cases { + got := IsProjectRootFolder(path) + if got != want { + t.Errorf("IsProjectRootFolder(%q) = %v, want %v", path, got, want) + } + } +}