cursedbelt 5.34.0 → 5.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/react/file-tree/FileTree.d.ts +27 -1
  2. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  3. package/dist/react/file-tree/FileTree.js +183 -9
  4. package/dist/react/file-tree/FileTree.js.map +1 -1
  5. package/dist/react/file-tree/FolderViewMenus.d.ts +88 -0
  6. package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -0
  7. package/dist/react/file-tree/FolderViewMenus.js +121 -0
  8. package/dist/react/file-tree/FolderViewMenus.js.map +1 -0
  9. package/dist/react/file-tree/FolderViewPanels.d.ts +78 -0
  10. package/dist/react/file-tree/FolderViewPanels.d.ts.map +1 -0
  11. package/dist/react/file-tree/FolderViewPanels.js +155 -0
  12. package/dist/react/file-tree/FolderViewPanels.js.map +1 -0
  13. package/dist/react/file-tree/fileTree.css +146 -0
  14. package/dist/react/file-tree/folderView.d.ts +117 -0
  15. package/dist/react/file-tree/folderView.d.ts.map +1 -0
  16. package/dist/react/file-tree/folderView.js +230 -0
  17. package/dist/react/file-tree/folderView.js.map +1 -0
  18. package/dist/react/file-tree/folderViewPanels.css +217 -0
  19. package/dist/react/file-tree/index.d.ts +2 -0
  20. package/dist/react/file-tree/index.d.ts.map +1 -1
  21. package/dist/react/file-tree/index.js +9 -0
  22. package/dist/react/file-tree/index.js.map +1 -1
  23. package/dist/react/file-tree/useFolderView.d.ts +56 -0
  24. package/dist/react/file-tree/useFolderView.d.ts.map +1 -0
  25. package/dist/react/file-tree/useFolderView.js +79 -0
  26. package/dist/react/file-tree/useFolderView.js.map +1 -0
  27. package/dist/react/filter-rail/FilterRail.d.ts +7 -0
  28. package/dist/react/filter-rail/FilterRail.d.ts.map +1 -1
  29. package/dist/react/filter-rail/FilterRail.js +8 -1
  30. package/dist/react/filter-rail/FilterRail.js.map +1 -1
  31. package/dist/react/filter-rail/RailSection.d.ts +7 -1
  32. package/dist/react/filter-rail/RailSection.d.ts.map +1 -1
  33. package/dist/react/filter-rail/RailSection.js +5 -3
  34. package/dist/react/filter-rail/RailSection.js.map +1 -1
  35. package/dist/react/filter-rail/headerContextMenu.d.ts +8 -0
  36. package/dist/react/filter-rail/headerContextMenu.d.ts.map +1 -0
  37. package/dist/react/filter-rail/headerContextMenu.js +31 -0
  38. package/dist/react/filter-rail/headerContextMenu.js.map +1 -0
  39. package/dist/react/media/denseGallery.css +46 -0
  40. package/dist/react/media-gallery/rememberedPreference.d.ts +10 -1
  41. package/dist/react/media-gallery/rememberedPreference.d.ts.map +1 -1
  42. package/dist/react/media-gallery/rememberedPreference.js +12 -3
  43. package/dist/react/media-gallery/rememberedPreference.js.map +1 -1
  44. package/package.json +1 -1
  45. package/scripts/guardrails-baseline.json +1 -1
  46. package/src/react/file-tree/FileTree.tsx +323 -4
  47. package/src/react/file-tree/FolderViewMenus.tsx +242 -0
  48. package/src/react/file-tree/FolderViewPanels.tsx +402 -0
  49. package/src/react/file-tree/fileTree.css +146 -0
  50. package/src/react/file-tree/folderView.spec.ts +174 -0
  51. package/src/react/file-tree/folderView.ts +316 -0
  52. package/src/react/file-tree/folderViewPanels.css +217 -0
  53. package/src/react/file-tree/folderViewTree.spec.tsx +252 -0
  54. package/src/react/file-tree/index.ts +14 -0
  55. package/src/react/file-tree/useFolderView.ts +152 -0
  56. package/src/react/filter-rail/FilterRail.tsx +35 -6
  57. package/src/react/filter-rail/RailSection.tsx +10 -2
  58. package/src/react/filter-rail/headerContextMenu.spec.tsx +62 -0
  59. package/src/react/filter-rail/headerContextMenu.ts +37 -0
  60. package/src/react/media/denseGallery.css +46 -0
  61. package/src/react/media-gallery/rememberedPreference.ts +10 -2
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { type FileTreeNode, flattenFileTree } from 'cursedbelt-core/file-tree/file-tree-model';
3
+ import {
4
+ applyFolderView,
5
+ EMPTY_FOLDER_MARKS,
6
+ EMPTY_FOLDER_VIEW,
7
+ type FolderMarks,
8
+ type FolderViewLocal,
9
+ normalizeTag,
10
+ parseFolderMarks,
11
+ parseFolderViewLocal,
12
+ serializeFolderMarks,
13
+ setHidden,
14
+ setTagFilter,
15
+ tagCounts,
16
+ tagHue,
17
+ TAG_HUES,
18
+ toggleFolderTag,
19
+ } from './folderView.js';
20
+
21
+ /** The rules of `folderView.ts`, with no renderer anywhere near them. */
22
+
23
+ const folder = (id: string, children: FileTreeNode[] = []): FileTreeNode => ({
24
+ id,
25
+ name: id,
26
+ kind: 'folder',
27
+ children,
28
+ });
29
+ const file = (id: string): FileTreeNode => ({ id, name: id, kind: 'file' });
30
+
31
+ // home/ tagged home
32
+ // photos/
33
+ // a.jpg
34
+ // work/ tagged work
35
+ // dynatrace/ tagged dynatrace
36
+ // notes.md
37
+ // slides.key
38
+ // code/
39
+ // cursedforge/ tagged code
40
+ // scratch/
41
+ // readme.md (a top-level file)
42
+ const TREE: FileTreeNode[] = [
43
+ folder('home', [folder('photos', [file('a.jpg')])]),
44
+ folder('work', [folder('dynatrace', [file('notes.md')]), file('slides.key')]),
45
+ folder('code', [folder('cursedforge'), folder('scratch')]),
46
+ file('readme.md'),
47
+ ];
48
+
49
+ const MARKS: FolderMarks = {
50
+ hidden: [],
51
+ tags: { home: ['home'], work: ['work'], dynatrace: ['dynatrace'], cursedforge: ['code'] },
52
+ };
53
+
54
+ const ids = (nodes: readonly FileTreeNode[]) => flattenFileTree(nodes, []).map((r) => r.node.id);
55
+ const view = (local: Partial<FolderViewLocal>): FolderViewLocal => ({ ...EMPTY_FOLDER_VIEW, ...local });
56
+
57
+ describe('nothing marked', () => {
58
+ test('hands the SAME array back — a tree with no marks pays nothing', () => {
59
+ const out = applyFolderView(TREE, EMPTY_FOLDER_MARKS, EMPTY_FOLDER_VIEW);
60
+ expect(out.nodes).toBe(TREE);
61
+ expect(out.hiddenCount).toBe(0);
62
+ });
63
+ });
64
+
65
+ describe('hiding a folder', () => {
66
+ const hidden: FolderMarks = { ...MARKS, hidden: ['work'] };
67
+
68
+ test('takes the folder and everything in it out of the tree', () => {
69
+ const out = applyFolderView(TREE, hidden, EMPTY_FOLDER_VIEW);
70
+ expect(ids(out.nodes)).not.toContain('work');
71
+ expect(ids(out.nodes)).not.toContain('notes.md');
72
+ expect(ids(out.nodes)).toContain('home');
73
+ expect(out.hiddenCount).toBe(1);
74
+ });
75
+
76
+ test('🔴 "show hidden" draws it back, marked `self`, and what is inside it `inherited`', () => {
77
+ const out = applyFolderView(TREE, hidden, view({ showHidden: true }));
78
+ expect(ids(out.nodes)).toEqual(ids(TREE));
79
+ expect(out.hiddenMarks.get('work')).toBe('self');
80
+ expect(out.hiddenMarks.get('dynatrace')).toBe('inherited');
81
+ expect(out.hiddenMarks.get('slides.key')).toBe('inherited');
82
+ expect(out.hiddenMarks.has('home')).toBe(false);
83
+ });
84
+
85
+ test('a folder hidden INSIDE a hidden folder is counted, so the menu says (2)', () => {
86
+ const out = applyFolderView(TREE, { ...hidden, hidden: ['work', 'dynatrace'] }, EMPTY_FOLDER_VIEW);
87
+ expect(out.hiddenCount).toBe(2);
88
+ });
89
+
90
+ test('setHidden adds once, removes, and returns the SAME value when nothing changed', () => {
91
+ const once = setHidden(MARKS, ['work'], true);
92
+ expect(setHidden(once, ['work'], true)).toBe(once);
93
+ expect(setHidden(once, ['work'], false).hidden).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe('filtering by tag (this device)', () => {
98
+ test('"only home + code" keeps those folders WITH their subtrees, and the path to a nested one', () => {
99
+ const out = applyFolderView(TREE, MARKS, view({ only: ['home', 'code'] }));
100
+ const shown = ids(out.nodes);
101
+ expect(shown).toContain('a.jpg'); // inside home: tagging a folder means what is in it too
102
+ expect(shown).toContain('code'); // the way to cursedforge…
103
+ expect(shown).toContain('cursedforge');
104
+ expect(shown).not.toContain('scratch'); // …pruned to just that way
105
+ expect(shown).not.toContain('work');
106
+ });
107
+
108
+ test('"only work + dynatrace" — the owner\'s second example', () => {
109
+ const shown = ids(applyFolderView(TREE, MARKS, view({ only: ['work', 'dynatrace'] })).nodes);
110
+ expect(shown).toEqual(expect.arrayContaining(['work', 'dynatrace', 'notes.md', 'slides.key']));
111
+ expect(shown).not.toContain('home');
112
+ });
113
+
114
+ test('🔴 "hide" wins over "only" — a hidden tag inside a shown one is still gone', () => {
115
+ const shown = ids(
116
+ applyFolderView(TREE, MARKS, view({ only: ['work'], hide: ['dynatrace'] })).nodes,
117
+ );
118
+ expect(shown).toContain('slides.key');
119
+ expect(shown).not.toContain('dynatrace');
120
+ expect(shown).not.toContain('notes.md');
121
+ });
122
+
123
+ test('a top-level file is in no folder, so a tag filter leaves it alone', () => {
124
+ expect(ids(applyFolderView(TREE, MARKS, view({ only: ['home'] })).nodes)).toContain('readme.md');
125
+ });
126
+
127
+ test('setTagFilter moves a tag between only, hide and neither — never both', () => {
128
+ let local = setTagFilter(EMPTY_FOLDER_VIEW, 'Home', 'only');
129
+ expect(local.only).toEqual(['home']);
130
+ local = setTagFilter(local, 'home', 'hide');
131
+ expect(local).toMatchObject({ only: [], hide: ['home'] });
132
+ expect(setTagFilter(local, 'home', null)).toMatchObject({ only: [], hide: [] });
133
+ });
134
+ });
135
+
136
+ describe('tags', () => {
137
+ test('one spelling: trimmed, collapsed, lower-cased, capped — and nothing is not a tag', () => {
138
+ expect(normalizeTag(' Dyna Trace ')).toBe('dyna trace');
139
+ expect(normalizeTag(' ')).toBeNull();
140
+ expect(normalizeTag('x'.repeat(80))?.length).toBe(32);
141
+ });
142
+
143
+ test('toggling adds and removes; the last one off forgets the folder', () => {
144
+ const on = toggleFolderTag(EMPTY_FOLDER_MARKS, 'f', 'Home');
145
+ expect(on.tags.f).toEqual(['home']);
146
+ expect(toggleFolderTag(on, 'f', 'home').tags).toEqual({});
147
+ });
148
+
149
+ test('the tag list is derived — most used first — so an unused tag simply stops existing', () => {
150
+ const marks = toggleFolderTag(toggleFolderTag(MARKS, 'photos', 'home'), 'cursedforge', 'code');
151
+ expect(tagCounts(marks)[0]).toEqual({ tag: 'home', count: 2 });
152
+ expect(tagCounts(marks).some((t) => t.tag === 'code')).toBe(false);
153
+ });
154
+
155
+ test('a tag is always the same colour, and always one of the palette', () => {
156
+ expect(tagHue('home')).toBe(tagHue('home'));
157
+ for (const tag of ['home', 'work', 'code', 'dynatrace', 'x'])
158
+ expect(tagHue(tag)).toBeLessThan(TAG_HUES);
159
+ });
160
+ });
161
+
162
+ describe('storage', () => {
163
+ test('marks round-trip, and empty marks store as nothing so the key can be forgotten', () => {
164
+ const marks: FolderMarks = { hidden: ['a'], tags: { b: ['home'] } };
165
+ expect(parseFolderMarks(serializeFolderMarks(marks))).toEqual(marks);
166
+ expect(serializeFolderMarks(EMPTY_FOLDER_MARKS)).toBeNull();
167
+ });
168
+
169
+ test('🔴 a corrupt value reads as NOTHING rather than throwing — it must never cost the tree', () => {
170
+ expect(parseFolderMarks('{not json')).toEqual(EMPTY_FOLDER_MARKS);
171
+ expect(parseFolderMarks('{"hidden":"x","tags":[1]}')).toEqual({ hidden: [], tags: {} });
172
+ expect(parseFolderViewLocal('[]')).toEqual({ showHidden: false, only: [], hide: [] });
173
+ });
174
+ });
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Hidden folders and folder tags — the arithmetic, with no DOM.
3
+ *
4
+ * Owner, 2026-09-30: *"add an option to the right click menu to hide a folder … In the menu
5
+ * put a show/hide hidden folders option … Add the ability for tagging folders … then being
6
+ * able to hide or show ones by tag or tags like just seeing folders tagged as 'home' and
7
+ * 'code' or 'work' and 'dynatrace' … The general hiding is persisted for all machines. Hiding
8
+ * or showing by tag can be stored per machine."*
9
+ *
10
+ * ── 🔴 Two stores, because the request names two scopes ─────────────────────────────────
11
+ *
12
+ * · {@link FolderMarks} — WHAT a folder is: hidden or not, and its tags. A fact about the
13
+ * folder, so it follows the person to every device (the app syncs it — see
14
+ * `useFolderView`, which announces it on the preference bus like every other synced key).
15
+ * · {@link FolderViewLocal} — HOW this device is looking: whether hidden folders are drawn,
16
+ * and which tags it is narrowed to. *"viewing the 'home' tag at home doesn't change my
17
+ * work computer"*, so this is never announced and never leaves the browser.
18
+ *
19
+ * Hiding a folder in general and filtering by tag are therefore different verbs on purpose:
20
+ * one is a decision about the folder, the other is a lens on the tree.
21
+ *
22
+ * ── The rules {@link applyFolderView} holds ─────────────────────────────────────────────
23
+ *
24
+ * 1. A hidden folder takes its subtree with it. With "show hidden" on, the folder is drawn
25
+ * marked `self` (italic, faded) and everything under it marked `inherited` (faded only),
26
+ * so the one to unhide is the one that looks different from its children.
27
+ * 2. "Hide tagged" wins over everything: a folder carrying a hidden tag, or inside one, is
28
+ * gone — Gmail's and Linear's "is not" filter.
29
+ * 3. "Only tagged" keeps a tagged folder WITH its whole subtree (tagging `code` means the
30
+ * code inside it too), and keeps the folders ON THE WAY to a tagged folder so it can be
31
+ * reached — pruned to just that path, the way a search keeps the folders above a match.
32
+ * 4. Files at the top level are not in any folder and no folder verb can reach them, so a
33
+ * tag filter leaves them alone. A file inside a folder follows its folder.
34
+ * 5. Tags are compared lower-cased and trimmed: `Home` and `home ` are one tag, the same
35
+ * rule GitHub topics and Obsidian tags hold, so a typo in case never splits a filter.
36
+ */
37
+ import type { FileTreeNode } from 'cursedbelt-core/file-tree/file-tree-model';
38
+
39
+ /** The synced half — what each folder IS. Keyed by `FileTreeNode.id`. */
40
+ export interface FolderMarks {
41
+ hidden: readonly string[];
42
+ tags: Readonly<Record<string, readonly string[]>>;
43
+ }
44
+
45
+ /** The per-device half — how THIS browser is looking at the tree. */
46
+ export interface FolderViewLocal {
47
+ /** Draw hidden folders (marked) instead of leaving them out. Finder's ⇧⌘. */
48
+ showHidden: boolean;
49
+ /** Narrow the tree to folders carrying any of these. Empty = no narrowing. */
50
+ only: readonly string[];
51
+ /** Leave out folders carrying any of these. Wins over {@link only}. */
52
+ hide: readonly string[];
53
+ }
54
+
55
+ export const EMPTY_FOLDER_MARKS: FolderMarks = Object.freeze({
56
+ hidden: Object.freeze([]) as readonly string[],
57
+ tags: Object.freeze({}),
58
+ });
59
+
60
+ export const EMPTY_FOLDER_VIEW: FolderViewLocal = Object.freeze({
61
+ showHidden: false,
62
+ only: Object.freeze([]) as readonly string[],
63
+ hide: Object.freeze([]) as readonly string[],
64
+ });
65
+
66
+ /** Longest tag kept. A tag is a word or two; anything longer is a sentence in the wrong field. */
67
+ export const MAX_TAG_CHARS = 32;
68
+
69
+ /**
70
+ * The one spelling of a tag: trimmed, inner whitespace collapsed, lower-cased, capped.
71
+ * `null` for nothing left — the caller treats that as "no tag", never as `""`.
72
+ */
73
+ export function normalizeTag(raw: string): string | null {
74
+ const tag = raw.trim().replace(/\s+/g, ' ').toLowerCase().slice(0, MAX_TAG_CHARS).trim();
75
+ return tag.length > 0 ? tag : null;
76
+ }
77
+
78
+ function uniqueTags(list: readonly string[]): string[] {
79
+ const out: string[] = [];
80
+ for (const raw of list) {
81
+ const tag = typeof raw === 'string' ? normalizeTag(raw) : null;
82
+ if (tag && !out.includes(tag)) out.push(tag);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Read stored marks. Anything malformed reads as nothing rather than throwing — a corrupt
89
+ * value must cost the person their marks on that one value, never the tree.
90
+ */
91
+ export function parseFolderMarks(raw: string | null | undefined): FolderMarks {
92
+ if (!raw) return EMPTY_FOLDER_MARKS;
93
+ try {
94
+ const value = JSON.parse(raw) as { hidden?: unknown; tags?: unknown };
95
+ const hidden = Array.isArray(value.hidden)
96
+ ? [...new Set(value.hidden.filter((id): id is string => typeof id === 'string' && id !== ''))]
97
+ : [];
98
+ const tags: Record<string, string[]> = {};
99
+ if (value.tags && typeof value.tags === 'object' && !Array.isArray(value.tags)) {
100
+ for (const [id, list] of Object.entries(value.tags as Record<string, unknown>)) {
101
+ if (!Array.isArray(list)) continue;
102
+ const clean = uniqueTags(list as string[]);
103
+ if (clean.length > 0) tags[id] = clean;
104
+ }
105
+ }
106
+ return { hidden, tags };
107
+ } catch {
108
+ return EMPTY_FOLDER_MARKS;
109
+ }
110
+ }
111
+
112
+ /** The stored form. `null` when there is nothing to keep, so an app can forget the key. */
113
+ export function serializeFolderMarks(marks: FolderMarks): string | null {
114
+ const tags = Object.fromEntries(Object.entries(marks.tags).filter(([, list]) => list.length > 0));
115
+ if (marks.hidden.length === 0 && Object.keys(tags).length === 0) return null;
116
+ return JSON.stringify({ v: 1, hidden: marks.hidden, tags });
117
+ }
118
+
119
+ /** Read a device's view. Same forgiving rule as {@link parseFolderMarks}. */
120
+ export function parseFolderViewLocal(raw: string | null | undefined): FolderViewLocal {
121
+ if (!raw) return EMPTY_FOLDER_VIEW;
122
+ try {
123
+ const value = JSON.parse(raw) as { showHidden?: unknown; only?: unknown; hide?: unknown };
124
+ return {
125
+ showHidden: value.showHidden === true,
126
+ only: Array.isArray(value.only) ? uniqueTags(value.only as string[]) : [],
127
+ hide: Array.isArray(value.hide) ? uniqueTags(value.hide as string[]) : [],
128
+ };
129
+ } catch {
130
+ return EMPTY_FOLDER_VIEW;
131
+ }
132
+ }
133
+
134
+ /** Hide or unhide these ids — a new value; the input is never touched. */
135
+ export function setHidden(marks: FolderMarks, ids: readonly string[], hidden: boolean): FolderMarks {
136
+ const next = hidden
137
+ ? [...marks.hidden, ...ids.filter((id) => !marks.hidden.includes(id))]
138
+ : marks.hidden.filter((id) => !ids.includes(id));
139
+ return next.length === marks.hidden.length && next.every((id, i) => id === marks.hidden[i])
140
+ ? marks
141
+ : { ...marks, hidden: next };
142
+ }
143
+
144
+ /** Replace one folder's tags. An empty list forgets the folder entirely. */
145
+ export function setFolderTags(marks: FolderMarks, id: string, tags: readonly string[]): FolderMarks {
146
+ const clean = uniqueTags(tags);
147
+ const { [id]: _old, ...rest } = marks.tags;
148
+ return { ...marks, tags: clean.length > 0 ? { ...rest, [id]: clean } : rest };
149
+ }
150
+
151
+ /** Add or take away ONE tag on one folder — the tag editor's checkbox. */
152
+ export function toggleFolderTag(marks: FolderMarks, id: string, raw: string): FolderMarks {
153
+ const tag = normalizeTag(raw);
154
+ if (!tag) return marks;
155
+ const current = marks.tags[id] ?? [];
156
+ return setFolderTags(
157
+ marks,
158
+ id,
159
+ current.includes(tag) ? current.filter((t) => t !== tag) : [...current, tag],
160
+ );
161
+ }
162
+
163
+ /** A tag that exists, and how many folders carry it — most-used first, then by name. */
164
+ export interface TagCount {
165
+ tag: string;
166
+ count: number;
167
+ }
168
+
169
+ /**
170
+ * Every tag in use. Derived, never stored: a tag no folder carries any more simply stops
171
+ * existing, so there is no tag list to tidy and no orphan to delete.
172
+ */
173
+ export function tagCounts(marks: FolderMarks): TagCount[] {
174
+ const counts = new Map<string, number>();
175
+ for (const list of Object.values(marks.tags))
176
+ for (const tag of list) counts.set(tag, (counts.get(tag) ?? 0) + 1);
177
+ return [...counts]
178
+ .map(([tag, count]) => ({ tag, count }))
179
+ .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
180
+ }
181
+
182
+ /** How many colours a tag dot can be — `--cbft-tag-0` … `--cbft-tag-7` in fileTree.css. */
183
+ export const TAG_HUES = 8;
184
+
185
+ /**
186
+ * A tag's colour slot, from its name — the same tag is the same colour in every app, on every
187
+ * device, without a colour having to be chosen or stored. FNV-1a, because it is four lines and
188
+ * spreads short words well.
189
+ */
190
+ export function tagHue(tag: string): number {
191
+ let hash = 0x811c9dc5;
192
+ for (let i = 0; i < tag.length; i++) {
193
+ hash ^= tag.charCodeAt(i);
194
+ hash = Math.imul(hash, 0x01000193);
195
+ }
196
+ return (hash >>> 0) % TAG_HUES;
197
+ }
198
+
199
+ /** Whether a device's view narrows the tree by tag at all. */
200
+ export function tagFilterActive(local: FolderViewLocal): boolean {
201
+ return local.only.length > 0 || local.hide.length > 0;
202
+ }
203
+
204
+ /** Cycle one tag in the device's filter: `'only'`, `'hide'`, or `null` for neither. */
205
+ export function setTagFilter(
206
+ local: FolderViewLocal,
207
+ raw: string,
208
+ mode: 'only' | 'hide' | null,
209
+ ): FolderViewLocal {
210
+ const tag = normalizeTag(raw);
211
+ if (!tag) return local;
212
+ const only = local.only.filter((t) => t !== tag);
213
+ const hide = local.hide.filter((t) => t !== tag);
214
+ if (mode === 'only') only.push(tag);
215
+ if (mode === 'hide') hide.push(tag);
216
+ return { ...local, only, hide };
217
+ }
218
+
219
+ /** What {@link applyFolderView} hands back. */
220
+ export interface FolderViewResult {
221
+ /** The tree to draw — pruned, with new objects only where a child list changed. */
222
+ nodes: readonly FileTreeNode[];
223
+ /**
224
+ * Hidden rows that ARE drawn (show-hidden on): `self` for the folder that was hidden,
225
+ * `inherited` for everything inside it.
226
+ */
227
+ hiddenMarks: ReadonlyMap<string, 'self' | 'inherited'>;
228
+ /** Hidden folders the tree holds right now — the count beside "Show hidden folders". */
229
+ hiddenCount: number;
230
+ }
231
+
232
+ /**
233
+ * The tree this device should draw. See the header for the five rules.
234
+ *
235
+ * Returns the input array itself when nothing is marked and nothing is filtered, so a tree
236
+ * with no marks pays one check and no copy.
237
+ */
238
+ export function applyFolderView(
239
+ nodes: readonly FileTreeNode[],
240
+ marks: FolderMarks,
241
+ local: FolderViewLocal,
242
+ ): FolderViewResult {
243
+ const hiddenMarks = new Map<string, 'self' | 'inherited'>();
244
+ const filtering = tagFilterActive(local);
245
+ if (marks.hidden.length === 0 && !filtering) return { nodes, hiddenMarks, hiddenCount: 0 };
246
+
247
+ const hiddenSet = new Set(marks.hidden);
248
+ const only = new Set(local.only);
249
+ const hide = new Set(local.hide);
250
+ let hiddenCount = 0;
251
+
252
+ const carries = (id: string, set: ReadonlySet<string>): boolean =>
253
+ set.size > 0 && (marks.tags[id] ?? []).some((tag) => set.has(tag));
254
+
255
+ /**
256
+ * `null` = drop the node. `inHidden`: an ancestor is hidden. `matched`: an ancestor
257
+ * carries an "only" tag (or there is no "only" filter at all).
258
+ */
259
+ const visit = (
260
+ node: FileTreeNode,
261
+ depth: number,
262
+ inHidden: boolean,
263
+ matched: boolean,
264
+ ): FileTreeNode | null => {
265
+ const selfHidden = hiddenSet.has(node.id);
266
+ if (selfHidden) hiddenCount++;
267
+ const hiddenState = selfHidden ? 'self' : inHidden ? 'inherited' : null;
268
+ if (hiddenState && !local.showHidden) {
269
+ // Still counted below it — "Show hidden folders (3)" counts nested ones too.
270
+ if (node.children) countHidden(node.children);
271
+ return null;
272
+ }
273
+ if (carries(node.id, hide)) {
274
+ if (node.children) countHidden(node.children);
275
+ return null;
276
+ }
277
+ const isMatch = matched || carries(node.id, only);
278
+
279
+ if (node.kind !== 'folder') {
280
+ // Rule 4: a top-level file is in no folder; a nested one follows its folder.
281
+ if (!isMatch && depth > 0) return null;
282
+ if (hiddenState) hiddenMarks.set(node.id, hiddenState);
283
+ return node;
284
+ }
285
+
286
+ let children = node.children;
287
+ if (node.children) {
288
+ const kept: FileTreeNode[] = [];
289
+ let changed = false;
290
+ for (const child of node.children) {
291
+ const out = visit(child, depth + 1, hiddenState !== null, isMatch);
292
+ if (out) kept.push(out);
293
+ if (out !== child) changed = true;
294
+ }
295
+ if (changed) children = kept;
296
+ }
297
+ // Rule 3: an unmatched folder survives only as the way to a matched one.
298
+ if (!isMatch && (!children || children.length === 0)) return null;
299
+ if (hiddenState) hiddenMarks.set(node.id, hiddenState);
300
+ return children === node.children ? node : { ...node, children: children ?? [] };
301
+ };
302
+
303
+ const countHidden = (list: readonly FileTreeNode[]): void => {
304
+ for (const node of list) {
305
+ if (hiddenSet.has(node.id)) hiddenCount++;
306
+ if (node.children) countHidden(node.children);
307
+ }
308
+ };
309
+
310
+ const out: FileTreeNode[] = [];
311
+ for (const node of nodes) {
312
+ const kept = visit(node, 0, false, only.size === 0);
313
+ if (kept) out.push(kept);
314
+ }
315
+ return { nodes: out, hiddenMarks, hiddenCount };
316
+ }