cursedbelt 5.46.2 β†’ 5.47.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 (145) hide show
  1. package/dist/react/charts/IntervalTimeline.d.ts.map +1 -1
  2. package/dist/react/charts/IntervalTimeline.js +2 -2
  3. package/dist/react/charts/IntervalTimeline.js.map +1 -1
  4. package/dist/react/chat/DateMarker.d.ts +4 -0
  5. package/dist/react/chat/DateMarker.d.ts.map +1 -1
  6. package/dist/react/chat/DateMarker.js +15 -2
  7. package/dist/react/chat/DateMarker.js.map +1 -1
  8. package/dist/react/components/Button.d.ts.map +1 -1
  9. package/dist/react/components/Button.js +5 -0
  10. package/dist/react/components/Button.js.map +1 -1
  11. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  12. package/dist/react/components/DataTable/DataTable.js +1 -1
  13. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  14. package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
  15. package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
  16. package/dist/react/components/DataTable/SortHeader.js +22 -5
  17. package/dist/react/components/DataTable/SortHeader.js.map +1 -1
  18. package/dist/react/components/DataTable/types.d.ts +10 -0
  19. package/dist/react/components/DataTable/types.d.ts.map +1 -1
  20. package/dist/react/components/DataTable/types.js.map +1 -1
  21. package/dist/react/components/DropdownMenu.js +10 -4
  22. package/dist/react/components/DropdownMenu.js.map +1 -1
  23. package/dist/react/components/Field.d.ts +9 -1
  24. package/dist/react/components/Field.d.ts.map +1 -1
  25. package/dist/react/components/Field.js +3 -3
  26. package/dist/react/components/Field.js.map +1 -1
  27. package/dist/react/components/SecretInput.d.ts +4 -2
  28. package/dist/react/components/SecretInput.d.ts.map +1 -1
  29. package/dist/react/components/SecretInput.js +2 -2
  30. package/dist/react/components/SecretInput.js.map +1 -1
  31. package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
  32. package/dist/react/components/auth/AuthCard.js +5 -1
  33. package/dist/react/components/auth/AuthCard.js.map +1 -1
  34. package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
  35. package/dist/react/components/auth/PasswordSetFields.js +2 -2
  36. package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
  37. package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
  38. package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
  39. package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
  40. package/dist/react/components/auth/RegisterForm.js +1 -1
  41. package/dist/react/components/auth/RegisterForm.js.map +1 -1
  42. package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
  43. package/dist/react/components/auth/SignupForm.js +4 -2
  44. package/dist/react/components/auth/SignupForm.js.map +1 -1
  45. package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
  46. package/dist/react/components/auth/TotpEntry.js +18 -1
  47. package/dist/react/components/auth/TotpEntry.js.map +1 -1
  48. package/dist/react/components/auth/fields.d.ts +3 -1
  49. package/dist/react/components/auth/fields.d.ts.map +1 -1
  50. package/dist/react/components/auth/fields.js +6 -2
  51. package/dist/react/components/auth/fields.js.map +1 -1
  52. package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
  53. package/dist/react/components/calendar/DayAgenda.js +4 -2
  54. package/dist/react/components/calendar/DayAgenda.js.map +1 -1
  55. package/dist/react/components/calendar/EventChip.d.ts +5 -1
  56. package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
  57. package/dist/react/components/calendar/EventChip.js +10 -3
  58. package/dist/react/components/calendar/EventChip.js.map +1 -1
  59. package/dist/react/components/calendar/MonthGrid.js +1 -1
  60. package/dist/react/components/calendar/MonthGrid.js.map +1 -1
  61. package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
  62. package/dist/react/components/calendar/WeekView.js +2 -2
  63. package/dist/react/components/calendar/WeekView.js.map +1 -1
  64. package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
  65. package/dist/react/components/calendar/colorUtils.js +13 -11
  66. package/dist/react/components/calendar/colorUtils.js.map +1 -1
  67. package/dist/react/components/fieldErrorText.d.ts +12 -0
  68. package/dist/react/components/fieldErrorText.d.ts.map +1 -0
  69. package/dist/react/components/fieldErrorText.js +12 -0
  70. package/dist/react/components/fieldErrorText.js.map +1 -0
  71. package/dist/react/deep-link/resume.d.ts.map +1 -1
  72. package/dist/react/deep-link/resume.js +9 -2
  73. package/dist/react/deep-link/resume.js.map +1 -1
  74. package/dist/react/file-tree/FileTree.d.ts +3 -0
  75. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  76. package/dist/react/file-tree/FileTree.js +12 -1
  77. package/dist/react/file-tree/FileTree.js.map +1 -1
  78. package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
  79. package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
  80. package/dist/react/file-tree/FolderViewMenus.js +51 -15
  81. package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
  82. package/dist/react/file-tree/folderView.d.ts +25 -4
  83. package/dist/react/file-tree/folderView.d.ts.map +1 -1
  84. package/dist/react/file-tree/folderView.js +49 -9
  85. package/dist/react/file-tree/folderView.js.map +1 -1
  86. package/dist/react/file-tree/index.d.ts +2 -1
  87. package/dist/react/file-tree/index.d.ts.map +1 -1
  88. package/dist/react/file-tree/index.js +2 -1
  89. package/dist/react/file-tree/index.js.map +1 -1
  90. package/dist/react/file-tree/useFolderView.d.ts +34 -4
  91. package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
  92. package/dist/react/file-tree/useFolderView.js +43 -11
  93. package/dist/react/file-tree/useFolderView.js.map +1 -1
  94. package/dist/styles-areas/analytics.css +26 -25
  95. package/dist/styles-areas/calendar.css +4 -4
  96. package/dist/styles-areas/data-table.css +26 -25
  97. package/dist/styles-utilities.css +24 -9
  98. package/package.json +5 -1
  99. package/scripts/demoCoverage.spec.ts +33 -0
  100. package/scripts/demoCoverage.ts +124 -0
  101. package/src/react/charts/IntervalTimeline.tsx +6 -4
  102. package/src/react/chat/DateMarker.tsx +22 -6
  103. package/src/react/chat/MessageThread.spec.tsx +25 -1
  104. package/src/react/components/Button.spec.tsx +11 -0
  105. package/src/react/components/Button.tsx +5 -0
  106. package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
  107. package/src/react/components/DataTable/DataTable.tsx +1 -0
  108. package/src/react/components/DataTable/SortHeader.tsx +45 -5
  109. package/src/react/components/DataTable/types.ts +10 -0
  110. package/src/react/components/DropdownMenu.spec.tsx +26 -0
  111. package/src/react/components/DropdownMenu.tsx +10 -3
  112. package/src/react/components/Field.spec.tsx +24 -0
  113. package/src/react/components/Field.tsx +15 -5
  114. package/src/react/components/SecretInput.tsx +5 -1
  115. package/src/react/components/auth/AuthCard.spec.tsx +8 -0
  116. package/src/react/components/auth/AuthCard.tsx +4 -1
  117. package/src/react/components/auth/PasswordSetFields.tsx +7 -11
  118. package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
  119. package/src/react/components/auth/RegisterForm.tsx +6 -6
  120. package/src/react/components/auth/SignupForm.spec.tsx +17 -0
  121. package/src/react/components/auth/SignupForm.tsx +11 -10
  122. package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
  123. package/src/react/components/auth/TotpEntry.tsx +18 -1
  124. package/src/react/components/auth/fields.tsx +8 -1
  125. package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
  126. package/src/react/components/calendar/DayAgenda.tsx +5 -2
  127. package/src/react/components/calendar/EventChip.spec.tsx +12 -8
  128. package/src/react/components/calendar/EventChip.tsx +11 -6
  129. package/src/react/components/calendar/MonthGrid.tsx +1 -1
  130. package/src/react/components/calendar/WeekView.tsx +5 -3
  131. package/src/react/components/calendar/colorUtils.ts +13 -11
  132. package/src/react/components/fieldErrorText.tsx +17 -0
  133. package/src/react/deep-link/resume.spec.tsx +16 -0
  134. package/src/react/deep-link/resume.ts +9 -2
  135. package/src/react/file-tree/FileTree.tsx +14 -1
  136. package/src/react/file-tree/FolderViewMenus.tsx +57 -17
  137. package/src/react/file-tree/folderView.spec.ts +63 -1
  138. package/src/react/file-tree/folderView.ts +63 -12
  139. package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
  140. package/src/react/file-tree/index.ts +15 -1
  141. package/src/react/file-tree/useFolderView.ts +73 -12
  142. package/src/styles-areas/analytics.css +26 -25
  143. package/src/styles-areas/calendar.css +4 -4
  144. package/src/styles-areas/data-table.css +26 -25
  145. package/src/styles-utilities.css +24 -9
@@ -72,7 +72,9 @@ export function folderTargets(
72
72
  }
73
73
 
74
74
  /**
75
- * "Hide folder" and "Tags…" for a folder β€” the row's half of the feature, as menu items.
75
+ * "Hide on this device", "Hide everywhere" and "Tags…" for a folder β€” the row's half of the
76
+ * feature, as menu items. With no device half kept (`useFolderView({ device: 'memory' })`) the
77
+ * two hides are one "Hide folder", which means everywhere.
76
78
  *
77
79
  * Exported so an app's own β‹― menu offers exactly what the right-click does: spread it into
78
80
  * the list you hand `DropdownMenu`. `ids` is the folders it acts on (see {@link folderTargets});
@@ -82,24 +84,47 @@ export function folderViewMenuItems(
82
84
  view: FolderView,
83
85
  node: FileTreeNode,
84
86
  ids: readonly string[] = [node.id],
85
- hiddenState: 'self' | 'inherited' | null = view.marks.hidden.includes(node.id) ? 'self' : null,
87
+ hiddenState: 'self' | 'inherited' | null = view.hiddenIn(node.id) ? 'self' : null,
86
88
  ): FileTreeMenuItem[] {
87
89
  if (node.kind !== 'folder') return [];
88
90
  const many = ids.length > 1;
89
91
  const what = many ? `${ids.length} folders` : `β€œ${node.name}”`;
90
92
  const items: FileTreeMenuItem[] = [];
93
+ const everywhere = () => {
94
+ view.hide(ids);
95
+ said(`Hid ${what}, on every device`, () => view.unhide(ids, { scope: 'everywhere' }), many ? 'β€œShow hidden folders” (β‡§βŒ˜.) shows them again' : 'β€œShow hidden folders” (β‡§βŒ˜.) shows it again');
96
+ };
91
97
  if (hiddenState === 'self') {
92
- items.push({
93
- key: 'fv-unhide',
94
- label: many ? `Unhide ${ids.length} folders` : 'Unhide folder',
95
- title: `Show ${what} in the tree again, on every device`,
96
- onClick: () => {
97
- view.unhide(ids);
98
- said(`${many ? `${ids.length} folders are` : `${what} is`} back in the tree, on every device`, () =>
99
- view.hide(ids),
100
- );
101
- },
102
- });
98
+ // Which list holds it decides the words: "Unhide" alone would not say where it comes back.
99
+ const scope = ids.length === 1 ? view.hiddenIn(node.id) : null;
100
+ const inDevice = scope === 'device' || scope === 'both' || (scope === null && ids.some((id) => view.local.hidden.includes(id)));
101
+ const inEverywhere = scope === 'everywhere' || scope === 'both' || (scope === null && ids.some((id) => view.marks.hidden.includes(id)));
102
+ if (inDevice)
103
+ items.push({
104
+ key: 'fv-unhide-device',
105
+ label: many ? `Unhide ${ids.length} folders on this device` : 'Unhide on this device',
106
+ title: inEverywhere
107
+ ? `Take ${what} off this device's list β€” it stays hidden everywhere until that is undone too`
108
+ : `Show ${what} in the tree on this device again`,
109
+ onClick: () => {
110
+ view.unhide(ids, { scope: 'device' });
111
+ said(`${many ? `${ids.length} folders are` : `${what} is`} back on this device`, () =>
112
+ view.hide(ids, { scope: 'device' }),
113
+ );
114
+ },
115
+ });
116
+ if (inEverywhere)
117
+ items.push({
118
+ key: 'fv-unhide',
119
+ label: many ? `Unhide ${ids.length} folders everywhere` : 'Unhide everywhere',
120
+ title: `Show ${what} in the tree again, on every device`,
121
+ onClick: () => {
122
+ view.unhide(ids, { scope: 'everywhere' });
123
+ said(`${many ? `${ids.length} folders are` : `${what} is`} back in the tree, on every device`, () =>
124
+ view.hide(ids),
125
+ );
126
+ },
127
+ });
103
128
  } else if (hiddenState === 'inherited') {
104
129
  items.push({
105
130
  key: 'fv-unhide',
@@ -107,15 +132,30 @@ export function folderViewMenuItems(
107
132
  disabled: true,
108
133
  title: 'It is inside a hidden folder β€” unhide that folder to bring this one back',
109
134
  });
135
+ } else if (view.deviceHiding) {
136
+ items.push(
137
+ {
138
+ key: 'fv-hide-device',
139
+ label: many ? `Hide ${ids.length} folders on this device` : 'Hide on this device',
140
+ title: `Leave ${what} out of the tree in this browser only. β€œShow hidden folders” (β‡§βŒ˜.) brings it back.`,
141
+ onClick: () => {
142
+ view.hide(ids, { scope: 'device' });
143
+ said(`Hid ${what}, on this device`, () => view.unhide(ids, { scope: 'device' }), 'β€œShow hidden folders” (β‡§βŒ˜.) shows it again');
144
+ },
145
+ },
146
+ {
147
+ key: 'fv-hide',
148
+ label: many ? `Hide ${ids.length} folders everywhere` : 'Hide everywhere',
149
+ title: `Leave ${what} out of the tree on every device. β€œShow hidden folders” (β‡§βŒ˜.) brings it back.`,
150
+ onClick: everywhere,
151
+ },
152
+ );
110
153
  } else {
111
154
  items.push({
112
155
  key: 'fv-hide',
113
156
  label: many ? `Hide ${ids.length} folders` : 'Hide folder',
114
157
  title: `Leave ${what} out of the tree on every device. β€œShow hidden folders” (β‡§βŒ˜.) brings it back.`,
115
- onClick: () => {
116
- view.hide(ids);
117
- said(`Hid ${what}, on every device`, () => view.unhide(ids), many ? 'β€œShow hidden folders” (β‡§βŒ˜.) shows them again' : 'β€œShow hidden folders” (β‡§βŒ˜.) shows it again');
118
- },
158
+ onClick: everywhere,
119
159
  });
120
160
  }
121
161
  const current = view.marks.tags[node.id] ?? [];
@@ -6,9 +6,11 @@ import {
6
6
  EMPTY_FOLDER_VIEW,
7
7
  type FolderMarks,
8
8
  type FolderViewLocal,
9
+ hiddenScope,
9
10
  normalizeTag,
10
11
  parseFolderMarks,
11
12
  parseFolderViewLocal,
13
+ remapFolderMarks,
12
14
  serializeFolderMarks,
13
15
  setHidden,
14
16
  setTagFilter,
@@ -94,6 +96,41 @@ describe('hiding a folder', () => {
94
96
  });
95
97
  });
96
98
 
99
+ describe('hidden on THIS device only', () => {
100
+ test('πŸ”΄ a folder in the device list is gone from the tree, subtree and all, like an everywhere one', () => {
101
+ const out = applyFolderView(TREE, MARKS, view({ hidden: ['work'] }));
102
+ expect(ids(out.nodes)).not.toContain('work');
103
+ expect(ids(out.nodes)).not.toContain('dynatrace');
104
+ expect(out.hiddenCount).toBe(1);
105
+ });
106
+
107
+ test('"show hidden" draws a device-hidden folder back, marked `self`', () => {
108
+ const out = applyFolderView(TREE, MARKS, view({ hidden: ['work'], showHidden: true }));
109
+ expect(out.hiddenMarks.get('work')).toBe('self');
110
+ expect(out.hiddenMarks.get('dynatrace')).toBe('inherited');
111
+ });
112
+
113
+ test('one folder in both lists counts once', () => {
114
+ const out = applyFolderView(TREE, { ...MARKS, hidden: ['work'] }, view({ hidden: ['work'] }));
115
+ expect(out.hiddenCount).toBe(1);
116
+ });
117
+
118
+ test('hiddenScope names which list holds a folder', () => {
119
+ const marks = { ...MARKS, hidden: ['work', 'home'] };
120
+ const local = view({ hidden: ['home', 'code'] });
121
+ expect(hiddenScope(marks, local, 'work')).toBe('everywhere');
122
+ expect(hiddenScope(marks, local, 'code')).toBe('device');
123
+ expect(hiddenScope(marks, local, 'home')).toBe('both');
124
+ expect(hiddenScope(marks, local, 'scratch')).toBeNull();
125
+ });
126
+
127
+ test('setHidden works on the device list too', () => {
128
+ const local = setHidden(EMPTY_FOLDER_VIEW, ['work'], true);
129
+ expect(local.hidden).toEqual(['work']);
130
+ expect(local.showHidden).toBe(false);
131
+ });
132
+ });
133
+
97
134
  describe('filtering by tag (this device)', () => {
98
135
  test('"only home + code" keeps those folders WITH their subtrees, and the path to a nested one', () => {
99
136
  const out = applyFolderView(TREE, MARKS, view({ only: ['home', 'code'] }));
@@ -159,6 +196,30 @@ describe('tags', () => {
159
196
  });
160
197
  });
161
198
 
199
+ describe('remapFolderMarks β€” a path-keyed tree renamed a folder', () => {
200
+ const marks: FolderMarks = {
201
+ hidden: ['a/b', 'c'],
202
+ tags: { 'a/b': ['work'], 'a/b/d': ['home'], c: ['code'] },
203
+ };
204
+ const rename = (id: string) => (id === 'a/b' || id.startsWith('a/b/') ? `x${id.slice(3)}` : id);
205
+
206
+ test('πŸ”΄ hidden and tags follow the folder and everything under it', () => {
207
+ const out = remapFolderMarks(marks, rename);
208
+ expect(out.hidden).toEqual(['x', 'c']);
209
+ expect(out.tags).toEqual({ x: ['work'], 'x/d': ['home'], c: ['code'] });
210
+ });
211
+
212
+ test('null forgets a mark β€” a deleted folder', () => {
213
+ const out = remapFolderMarks(marks, (id) => (id === 'c' ? null : id));
214
+ expect(out.hidden).toEqual(['a/b']);
215
+ expect(out.tags.c).toBeUndefined();
216
+ });
217
+
218
+ test('nothing moved β†’ the same value back', () => {
219
+ expect(remapFolderMarks(marks, (id) => id)).toBe(marks);
220
+ });
221
+ });
222
+
162
223
  describe('storage', () => {
163
224
  test('marks round-trip, and empty marks store as nothing so the key can be forgotten', () => {
164
225
  const marks: FolderMarks = { hidden: ['a'], tags: { b: ['home'] } };
@@ -169,6 +230,7 @@ describe('storage', () => {
169
230
  test('πŸ”΄ a corrupt value reads as NOTHING rather than throwing β€” it must never cost the tree', () => {
170
231
  expect(parseFolderMarks('{not json')).toEqual(EMPTY_FOLDER_MARKS);
171
232
  expect(parseFolderMarks('{"hidden":"x","tags":[1]}')).toEqual({ hidden: [], tags: {} });
172
- expect(parseFolderViewLocal('[]')).toEqual({ showHidden: false, only: [], hide: [] });
233
+ expect(parseFolderViewLocal('[]')).toEqual({ showHidden: false, only: [], hide: [], hidden: [] });
234
+ expect(parseFolderViewLocal('{"hidden":["a",1,"a",""]}').hidden).toEqual(['a']);
173
235
  });
174
236
  });
@@ -13,8 +13,11 @@
13
13
  * folder, so it follows the person to every device (the app syncs it β€” see
14
14
  * `useFolderView`, which announces it on the preference bus like every other synced key).
15
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.
16
+ * which tags it is narrowed to, and the folders hidden on THIS device only. *"viewing the
17
+ * 'home' tag at home doesn't change my work computer"*, so this is never announced and
18
+ * never leaves the browser. The device-only list is desk's owner request, 2026-09-30:
19
+ * *"a way to hide on the current device or to hide everywhere … and unhide them on just
20
+ * the device or unhide them everywhere"* β€” a folder hidden by either list is hidden.
18
21
  *
19
22
  * Hiding a folder in general and filtering by tag are therefore different verbs on purpose:
20
23
  * one is a decision about the folder, the other is a lens on the tree.
@@ -50,6 +53,8 @@ export interface FolderViewLocal {
50
53
  only: readonly string[];
51
54
  /** Leave out folders carrying any of these. Wins over {@link only}. */
52
55
  hide: readonly string[];
56
+ /** Folders hidden on this device only β€” {@link FolderMarks.hidden}'s per-device twin. */
57
+ hidden: readonly string[];
53
58
  }
54
59
 
55
60
  export const EMPTY_FOLDER_MARKS: FolderMarks = Object.freeze({
@@ -61,6 +66,7 @@ export const EMPTY_FOLDER_VIEW: FolderViewLocal = Object.freeze({
61
66
  showHidden: false,
62
67
  only: Object.freeze([]) as readonly string[],
63
68
  hide: Object.freeze([]) as readonly string[],
69
+ hidden: Object.freeze([]) as readonly string[],
64
70
  });
65
71
 
66
72
  /** Longest tag kept. A tag is a word or two; anything longer is a sentence in the wrong field. */
@@ -120,25 +126,69 @@ export function serializeFolderMarks(marks: FolderMarks): string | null {
120
126
  export function parseFolderViewLocal(raw: string | null | undefined): FolderViewLocal {
121
127
  if (!raw) return EMPTY_FOLDER_VIEW;
122
128
  try {
123
- const value = JSON.parse(raw) as { showHidden?: unknown; only?: unknown; hide?: unknown };
129
+ const value = JSON.parse(raw) as { showHidden?: unknown; only?: unknown; hide?: unknown; hidden?: unknown };
124
130
  return {
125
131
  showHidden: value.showHidden === true,
126
132
  only: Array.isArray(value.only) ? uniqueTags(value.only as string[]) : [],
127
133
  hide: Array.isArray(value.hide) ? uniqueTags(value.hide as string[]) : [],
134
+ hidden: Array.isArray(value.hidden)
135
+ ? [...new Set(value.hidden.filter((id): id is string => typeof id === 'string' && id !== ''))]
136
+ : [],
128
137
  };
129
138
  } catch {
130
139
  return EMPTY_FOLDER_VIEW;
131
140
  }
132
141
  }
133
142
 
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 {
143
+ /**
144
+ * Hide or unhide these ids in either list β€” {@link FolderMarks} (everywhere) or
145
+ * {@link FolderViewLocal} (this device). A new value; the input is never touched, and an
146
+ * unchanged list hands the input back so a caller can tell nothing happened.
147
+ */
148
+ export function setHidden<T extends { hidden: readonly string[] }>(
149
+ holder: T,
150
+ ids: readonly string[],
151
+ hidden: boolean,
152
+ ): T {
136
153
  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 };
154
+ ? [...holder.hidden, ...ids.filter((id) => !holder.hidden.includes(id))]
155
+ : holder.hidden.filter((id) => !ids.includes(id));
156
+ return next.length === holder.hidden.length && next.every((id, i) => id === holder.hidden[i])
157
+ ? holder
158
+ : { ...holder, hidden: next };
159
+ }
160
+
161
+ /**
162
+ * Re-key the marks after folders were renamed, moved or deleted β€” for a tree whose folder ids
163
+ * are PATHS (music, vault), where a rename hands the folder and everything under it a new id.
164
+ * `move(id)` answers each marked id with its new id, the same id to keep it, or `null` to forget
165
+ * it. Hidden and tags both follow; the input comes back untouched when nothing moved.
166
+ */
167
+ export function remapFolderMarks(marks: FolderMarks, move: (id: string) => string | null): FolderMarks {
168
+ let changed = false;
169
+ const hidden: string[] = [];
170
+ for (const id of marks.hidden) {
171
+ const to = move(id);
172
+ if (to !== id) changed = true;
173
+ if (to !== null && !hidden.includes(to)) hidden.push(to);
174
+ }
175
+ const tags: Record<string, readonly string[]> = {};
176
+ for (const [id, list] of Object.entries(marks.tags)) {
177
+ const to = move(id);
178
+ if (to !== id) changed = true;
179
+ if (to === null) continue;
180
+ tags[to] = tags[to] ? uniqueTags([...tags[to], ...list]) : list;
181
+ }
182
+ return changed ? { hidden, tags } : marks;
183
+ }
184
+
185
+ /** Where a folder is hidden, if it is: which of the two lists name it. */
186
+ export type HiddenScope = 'everywhere' | 'device' | 'both';
187
+
188
+ export function hiddenScope(marks: FolderMarks, local: FolderViewLocal, id: string): HiddenScope | null {
189
+ const everywhere = marks.hidden.includes(id);
190
+ const device = local.hidden.includes(id);
191
+ return everywhere && device ? 'both' : everywhere ? 'everywhere' : device ? 'device' : null;
142
192
  }
143
193
 
144
194
  /** Replace one folder's tags. An empty list forgets the folder entirely. */
@@ -242,9 +292,10 @@ export function applyFolderView(
242
292
  ): FolderViewResult {
243
293
  const hiddenMarks = new Map<string, 'self' | 'inherited'>();
244
294
  const filtering = tagFilterActive(local);
245
- if (marks.hidden.length === 0 && !filtering) return { nodes, hiddenMarks, hiddenCount: 0 };
295
+ if (marks.hidden.length === 0 && local.hidden.length === 0 && !filtering)
296
+ return { nodes, hiddenMarks, hiddenCount: 0 };
246
297
 
247
- const hiddenSet = new Set(marks.hidden);
298
+ const hiddenSet = new Set([...marks.hidden, ...local.hidden]);
248
299
  const only = new Set(local.only);
249
300
  const hide = new Set(local.hide);
250
301
  let hiddenCount = 0;
@@ -1,10 +1,11 @@
1
1
  import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
2
  import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { useState } from 'react';
3
4
  import type { FileTreeNode } from 'cursedbelt-core/file-tree/file-tree-model';
4
5
  import { onPreferenceWrite } from '../media-gallery/preferenceBus.js';
5
6
  import { toast } from '../Toast.js';
6
7
  import { FileTree, type FileTreeProps, preloadFileTreePanels } from './FileTree.js';
7
- import { useFolderView } from './useFolderView.js';
8
+ import { type UseFolderViewOptions, useFolderView } from './useFolderView.js';
8
9
 
9
10
  /**
10
11
  * Hidden folders and folder tags, through the gestures the owner named: right-click a folder,
@@ -27,8 +28,8 @@ const TREE: FileTreeNode[] = [
27
28
  { id: 'readme', name: 'readme.md', kind: 'file' },
28
29
  ];
29
30
 
30
- function Harness(props: Partial<FileTreeProps>) {
31
- const folderView = useFolderView({ key: KEY });
31
+ function Harness({ options, ...props }: Partial<FileTreeProps> & { options?: Partial<UseFolderViewOptions> }) {
32
+ const folderView = useFolderView({ key: KEY, ...options });
32
33
  return (
33
34
  <div>
34
35
  {/* biome-ignore lint/a11y/noStaticElementInteractions: stands in for an app's header bar, whose right-click is exactly what is under test. */}
@@ -66,10 +67,10 @@ beforeEach(() => globalThis.localStorage.clear());
66
67
  afterEach(cleanup);
67
68
 
68
69
  describe('hiding a folder', () => {
69
- test('πŸ”΄ right-click a folder β†’ Hide folder β†’ it and its contents leave the tree', () => {
70
+ test('πŸ”΄ right-click a folder β†’ Hide everywhere β†’ it and its contents leave the tree', () => {
70
71
  render(<Harness />);
71
72
  fireEvent.contextMenu(row('Work'), { clientX: 10, clientY: 10 });
72
- fireEvent.click(item('Hide folder'));
73
+ fireEvent.click(item('Hide everywhere'));
73
74
  expect(screen.queryByText('Work')).toBeNull();
74
75
  expect(screen.queryByText('Dynatrace')).toBeNull();
75
76
  expect(screen.getByText('Home')).toBeDefined();
@@ -87,17 +88,17 @@ describe('hiding a folder', () => {
87
88
  try {
88
89
  render(<Harness />);
89
90
  fireEvent.contextMenu(row('Work'));
90
- fireEvent.click(item('Hide folder'));
91
+ fireEvent.click(item('Hide everywhere'));
91
92
  expect(said.map((s) => s.message)).toEqual(['Hid β€œWork”, on every device']);
92
93
  expect(screen.queryByText('Work')).toBeNull();
93
94
  act(() => said[0]?.undo());
94
95
  expect(screen.getByText('Work')).toBeDefined();
95
96
 
96
97
  fireEvent.contextMenu(row('Work'));
97
- fireEvent.click(item('Hide folder'));
98
+ fireEvent.click(item('Hide everywhere'));
98
99
  fireEvent.keyDown(row('Home'), { key: '.', code: 'Period', shiftKey: true, metaKey: true });
99
100
  fireEvent.contextMenu(row('Work'));
100
- fireEvent.click(item('Unhide folder'));
101
+ fireEvent.click(item('Unhide everywhere'));
101
102
  expect(said.at(-1)?.message).toBe('β€œWork” is back in the tree, on every device');
102
103
  expect(row('Work').getAttribute('data-hidden')).toBeNull();
103
104
  act(() => said.at(-1)?.undo());
@@ -110,7 +111,7 @@ describe('hiding a folder', () => {
110
111
  test('πŸ”΄ "Show hidden folders" draws them back β€” the hidden one italic-marked, its contents faded-marked', () => {
111
112
  render(<Harness />);
112
113
  fireEvent.contextMenu(row('Work'));
113
- fireEvent.click(item('Hide folder'));
114
+ fireEvent.click(item('Hide everywhere'));
114
115
  fireEvent.contextMenu(blank(), { clientX: 5, clientY: 300 });
115
116
  fireEvent.click(item(/Show hidden folders \(1\)/));
116
117
  expect(row('Work').getAttribute('data-hidden')).toBe('self');
@@ -121,14 +122,14 @@ describe('hiding a folder', () => {
121
122
  // Still hideable β€” the owner's "showing hidden folders still keeps them hideable": its own
122
123
  // menu now offers the way back.
123
124
  fireEvent.contextMenu(row('Work'));
124
- fireEvent.click(item('Unhide folder'));
125
+ fireEvent.click(item('Unhide everywhere'));
125
126
  expect(row('Work').getAttribute('data-hidden')).toBeNull();
126
127
  });
127
128
 
128
129
  test('the strip says hidden folders are showing, and its button leaves them out again', () => {
129
130
  render(<Harness />);
130
131
  fireEvent.contextMenu(row('Work'));
131
- fireEvent.click(item('Hide folder'));
132
+ fireEvent.click(item('Hide everywhere'));
132
133
  fireEvent.contextMenu(blank());
133
134
  fireEvent.click(item(/Show hidden folders/));
134
135
  expect(screen.getByText('Showing hidden')).toBeDefined();
@@ -140,7 +141,7 @@ describe('hiding a folder', () => {
140
141
  test('β‡§βŒ˜. on a row toggles hidden folders β€” Finder\'s chord', () => {
141
142
  render(<Harness />);
142
143
  fireEvent.contextMenu(row('Work'));
143
- fireEvent.click(item('Hide folder'));
144
+ fireEvent.click(item('Hide everywhere'));
144
145
  fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
145
146
  expect(row('Work').getAttribute('data-hidden')).toBe('self');
146
147
  fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
@@ -223,7 +224,7 @@ describe('πŸ”΄ what reaches other devices, and what does not', () => {
223
224
  try {
224
225
  render(<Harness />);
225
226
  fireEvent.contextMenu(row('Work'));
226
- fireEvent.click(item('Hide folder'));
227
+ fireEvent.click(item('Hide everywhere'));
227
228
  expect(heard).toEqual([KEY]);
228
229
  fireEvent.contextMenu(blank());
229
230
  fireEvent.click(item(/Show hidden folders/));
@@ -235,7 +236,100 @@ describe('πŸ”΄ what reaches other devices, and what does not', () => {
235
236
  });
236
237
  });
237
238
 
239
+ describe('πŸ”΄ hidden on THIS device only β€” desk\'s two lists, in the belt', () => {
240
+ test('Hide on this device leaves the tree, is never announced, and is stored on this device', () => {
241
+ const heard: string[] = [];
242
+ const stop = onPreferenceWrite((key) => heard.push(key));
243
+ try {
244
+ render(<Harness />);
245
+ fireEvent.contextMenu(row('Work'));
246
+ fireEvent.click(item('Hide on this device'));
247
+ expect(screen.queryByText('Work')).toBeNull();
248
+ expect(heard).toEqual([]);
249
+ expect(globalThis.localStorage.getItem(KEY)).toBeNull();
250
+ expect(globalThis.localStorage.getItem(`${KEY}:device`)).toContain('"hidden":["work"]');
251
+ } finally {
252
+ stop();
253
+ }
254
+ });
255
+
256
+ test('under Show hidden, the menu says WHICH list a folder is in, and unhides from that one', () => {
257
+ render(<Harness />);
258
+ fireEvent.contextMenu(row('Work'));
259
+ fireEvent.click(item('Hide on this device'));
260
+ fireEvent.contextMenu(row('Home'));
261
+ fireEvent.click(item('Hide everywhere'));
262
+ fireEvent.keyDown(row('readme.md'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
263
+
264
+ fireEvent.contextMenu(row('Work'));
265
+ expect(() => item('Unhide everywhere')).toThrow();
266
+ fireEvent.click(item('Unhide on this device'));
267
+ expect(row('Work').getAttribute('data-hidden')).toBeNull();
268
+
269
+ fireEvent.contextMenu(row('Home'));
270
+ expect(() => item('Unhide on this device')).toThrow();
271
+ fireEvent.click(item('Unhide everywhere'));
272
+ expect(row('Home').getAttribute('data-hidden')).toBeNull();
273
+ });
274
+ });
275
+
276
+ describe('πŸ”΄ the marks kept by the app β€” `store` and `device: \'memory\'`', () => {
277
+ test('with a store, a hide goes to the store and NOTHING touches localStorage or the bus', () => {
278
+ const written: (string | null)[] = [];
279
+ const heard: string[] = [];
280
+ const stop = onPreferenceWrite((key) => heard.push(key));
281
+ try {
282
+ function Kept() {
283
+ const [marks, setMarks] = useState<string | null>(null);
284
+ return (
285
+ <Harness
286
+ options={{
287
+ store: {
288
+ marks,
289
+ write: (next) => {
290
+ written.push(next);
291
+ setMarks(next);
292
+ },
293
+ },
294
+ device: 'memory',
295
+ }}
296
+ />
297
+ );
298
+ }
299
+ render(<Kept />);
300
+ fireEvent.contextMenu(row('Work'));
301
+ // No device half kept β†’ one hide, which means everywhere.
302
+ expect(() => item('Hide on this device')).toThrow();
303
+ fireEvent.click(item('Hide folder'));
304
+ expect(screen.queryByText('Work')).toBeNull();
305
+ expect(written).toHaveLength(1);
306
+ expect(written[0]).toContain('"hidden":["work"]');
307
+ fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
308
+ expect(row('Work').getAttribute('data-hidden')).toBe('self');
309
+ expect(globalThis.localStorage.length).toBe(0);
310
+ expect(heard).toEqual([]);
311
+ } finally {
312
+ stop();
313
+ }
314
+ });
315
+
316
+ test('the store is the truth: a value it hands in is what the tree draws', () => {
317
+ render(<Harness options={{ store: { marks: '{"hidden":["home"],"tags":{}}', write: () => undefined } }} />);
318
+ expect(screen.queryByText('Home')).toBeNull();
319
+ expect(screen.getByText('Work')).toBeDefined();
320
+ });
321
+ });
322
+
238
323
  describe('a row menu of the app\'s own', () => {
324
+ test('πŸ”΄ with onRowMenu and no rowMenu, right-click opens the APP\'s menu at the pointer', () => {
325
+ const opened: { id: string; x: number; y: number }[] = [];
326
+ render(<Harness onRowMenu={(node, at) => opened.push({ id: node.id, ...at })} />);
327
+ expect(fireEvent.contextMenu(row('readme.md'), { clientX: 40, clientY: 50 })).toBe(false);
328
+ expect(opened).toEqual([{ id: 'readme', x: 40, y: 50 }]);
329
+ // The tree drew no menu of its own over the app's.
330
+ expect(items()).toHaveLength(0);
331
+ });
332
+
239
333
  test('rowMenu items open on right-click, with Hide and Tags… after them on a folder', () => {
240
334
  let renamed = '';
241
335
  render(
@@ -246,7 +340,7 @@ describe('a row menu of the app\'s own', () => {
246
340
  fireEvent.contextMenu(row('Home'));
247
341
  const labels = items().map((el) => el.textContent);
248
342
  expect(labels[0]).toBe('Rename');
249
- expect(labels.some((l) => l?.startsWith('Hide folder'))).toBe(true);
343
+ expect(labels.some((l) => l?.startsWith('Hide everywhere'))).toBe(true);
250
344
  fireEvent.click(item('Rename'));
251
345
  expect(renamed).toBe('home');
252
346
  });
@@ -264,7 +358,8 @@ describe('a row menu of the app\'s own', () => {
264
358
  fireEvent.contextMenu(row('Home'));
265
359
  expect(items().map((el) => el.querySelector('.truncate')?.textContent)).toEqual([
266
360
  'Rename',
267
- 'Hide folder',
361
+ 'Hide on this device',
362
+ 'Hide everywhere',
268
363
  'Tags…',
269
364
  'Delete folder',
270
365
  ]);
@@ -274,7 +369,7 @@ describe('a row menu of the app\'s own', () => {
274
369
  render(<Harness />);
275
370
  act(() => row('Home').focus());
276
371
  fireEvent.keyDown(row('Home'), { key: 'F10', shiftKey: true });
277
- expect(item('Hide folder')).toBeDefined();
372
+ expect(item('Hide everywhere')).toBeDefined();
278
373
  });
279
374
 
280
375
  test('a tree with neither rowMenu nor folderView leaves right-click to the browser', () => {
@@ -65,4 +65,18 @@ export {
65
65
  folderViewMenuItems,
66
66
  withFolderItems,
67
67
  } from './FolderViewMenus.js';
68
- export { type FolderView, type UseFolderViewOptions, useFolderView } from './useFolderView.js';
68
+ export {
69
+ type FolderMarks,
70
+ type FolderViewLocal,
71
+ type HiddenScope,
72
+ parseFolderMarks,
73
+ remapFolderMarks,
74
+ serializeFolderMarks,
75
+ } from './folderView.js';
76
+ export {
77
+ type FolderMarksStore,
78
+ type FolderView,
79
+ type HideScope,
80
+ type UseFolderViewOptions,
81
+ useFolderView,
82
+ } from './useFolderView.js';