feat(zddc-server): empty listing for canonical project folders
Listing <project>/{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 <viewer-email>/ 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) <noreply@anthropic.com>
This commit is contained in:
parent
702ccf3be0
commit
3fc371752a
4 changed files with 135 additions and 1 deletions
|
|
@ -47,8 +47,21 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
// 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <project>/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 <viewer-email>/ 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: <project>/<canonical>.
|
||||
// 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/<email>/) 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue