An encrypted personal cloud built on the AT Protocol.

Scope tag filter bar to current directory [CL-250]

availableTags was accumulated globally across all decrypted directories
and never pruned, causing stale tags from previously-visited directories
to persist in the filter bar. Tags are now derived per-directory at
render time from the tree snapshot. Active tag filters clear on
directory navigation.

+38 -32
+24 -1
web/src/routes/cabinet/files/route.tsx
··· 1 + import { useEffect } from "react"; 1 2 import { createFileRoute, Link, Outlet, useMatch, useNavigate } from "@tanstack/react-router"; 2 3 import { 3 4 ListBulletsIcon, ··· 45 46 const ancestorsOf = useDocumentsStore((s) => s.ancestorsOf); 46 47 const items = useDocumentsStore((s) => s.items); 47 48 const treeSnapshot = useDocumentsStore((s) => s.treeSnapshot); 48 - const availableTags = useDocumentsStore((s) => s.availableTags); 49 49 const activeTagFilters = useDocumentsStore((s) => s.activeTagFilters); 50 50 const setTagFilters = useDocumentsStore((s) => s.setTagFilters); 51 51 ··· 56 56 const currentDirectoryName = currentDirectoryItem?.name ?? null; 57 57 58 58 const depth = segments.length > 0 ? segments.length + 1 : 1; 59 + 60 + // Tags scoped to the current directory (unfiltered — shows all tags, not just active) 61 + const availableTags = (() => { 62 + if (!treeSnapshot) return []; 63 + const targetUri = currentDirectoryUri ?? treeSnapshot.rootUri; 64 + if (!targetUri) return []; 65 + const dirEntry = treeSnapshot.directories[targetUri]; 66 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 67 + if (!dirEntry) return []; 68 + const tags = new Set( 69 + dirEntry.entries 70 + .map((uri) => items[uri]) 71 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: items may not be populated yet 72 + .filter((item): item is NonNullable<typeof item> => item != null) 73 + .flatMap((item) => item.tags), 74 + ); 75 + return [...tags].sort((a, b) => a.localeCompare(b)); 76 + })(); 77 + 78 + // Clear stale tag filters when navigating to a different directory 79 + useEffect(() => { 80 + setTagFilters([]); 81 + }, [currentDirectoryUri, setTagFilters]); 59 82 60 83 const footerText = (() => { 61 84 const targetUri = currentDirectoryUri ?? treeSnapshot?.rootUri;
+2 -4
web/src/stores/documents/decrypt.ts
··· 46 46 did: string, 47 47 privateKey: Uint8Array, 48 48 set: SetFn, 49 - ): Promise<string[]> { 49 + ): Promise<void> { 50 50 const encryption = record.value.encryption; 51 51 if (encryption.$type !== "app.opake.document#directEncryption") { 52 52 set((draft) => { ··· 56 56 decrypted: true, 57 57 }; 58 58 }); 59 - return []; 59 + return; 60 60 } 61 61 62 62 const contentKey = await unwrapDirectContentKey(encryption, did, privateKey); ··· 76 76 decrypted: true, 77 77 }; 78 78 }); 79 - 80 - return metadata.tags ?? []; 81 79 } 82 80 83 81 export function markDecryptionFailed(uri: string, set: SetFn): void {
+12 -27
web/src/stores/documents/store.ts
··· 36 36 loading: boolean; 37 37 error: string | null; 38 38 activeTagFilters: string[]; 39 - availableTags: string[]; 40 39 viewMode: "list" | "grid"; 41 40 42 41 readonly fetchAll: () => Promise<void>; ··· 60 59 loading: false, 61 60 error: null, 62 61 activeTagFilters: [], 63 - availableTags: [], 64 62 viewMode: "list", 65 63 66 64 fetchAll: async () => { ··· 76 74 draft.treeSnapshot = null; 77 75 draft.documentRecords = {}; 78 76 draft.decryptedDirectories = new Set(); 79 - draft.availableTags = []; 80 77 }); 81 78 82 79 try { ··· 174 171 const documentUris = dirEntry.entries.filter((uri) => !(uri in treeSnapshot.directories)); 175 172 176 173 // Decrypt sequentially to avoid overwhelming the worker 177 - const collectedTags = await documentUris.reduce( 178 - async (accPromise, uri) => { 179 - const acc = await accPromise; 180 - const record = documentRecords[uri]; 181 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 182 - if (!record) return acc; 183 - 184 - try { 185 - const tags = await decryptDocumentRecord(record, did, privateKey, set); 186 - return [...acc, ...tags]; 187 - } catch (error) { 188 - console.warn("[documents] failed to decrypt document:", uri, error); 189 - markDecryptionFailed(uri, set); 190 - return acc; 191 - } 192 - }, 193 - Promise.resolve([] as string[]), 194 - ); 174 + await documentUris.reduce(async (prev, uri) => { 175 + await prev; 176 + const record = documentRecords[uri]; 177 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 178 + if (!record) return; 195 179 196 - if (collectedTags.length > 0) { 197 - set((draft) => { 198 - const merged = new Set([...draft.availableTags, ...collectedTags]); 199 - draft.availableTags = [...merged].sort((a, b) => a.localeCompare(b)); 200 - }); 201 - } 180 + try { 181 + await decryptDocumentRecord(record, did, privateKey, set); 182 + } catch (error) { 183 + console.warn("[documents] failed to decrypt document:", uri, error); 184 + markDecryptionFailed(uri, set); 185 + } 186 + }, Promise.resolve()); 202 187 }, 203 188 204 189 itemsForDirectory: (directoryUri: string | null): FileItem[] => {