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.
- package/dist/react/charts/IntervalTimeline.d.ts.map +1 -1
- package/dist/react/charts/IntervalTimeline.js +2 -2
- package/dist/react/charts/IntervalTimeline.js.map +1 -1
- package/dist/react/chat/DateMarker.d.ts +4 -0
- package/dist/react/chat/DateMarker.d.ts.map +1 -1
- package/dist/react/chat/DateMarker.js +15 -2
- package/dist/react/chat/DateMarker.js.map +1 -1
- package/dist/react/components/Button.d.ts.map +1 -1
- package/dist/react/components/Button.js +5 -0
- package/dist/react/components/Button.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +1 -1
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
- package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
- package/dist/react/components/DataTable/SortHeader.js +22 -5
- package/dist/react/components/DataTable/SortHeader.js.map +1 -1
- package/dist/react/components/DataTable/types.d.ts +10 -0
- package/dist/react/components/DataTable/types.d.ts.map +1 -1
- package/dist/react/components/DataTable/types.js.map +1 -1
- package/dist/react/components/DropdownMenu.js +10 -4
- package/dist/react/components/DropdownMenu.js.map +1 -1
- package/dist/react/components/Field.d.ts +9 -1
- package/dist/react/components/Field.d.ts.map +1 -1
- package/dist/react/components/Field.js +3 -3
- package/dist/react/components/Field.js.map +1 -1
- package/dist/react/components/SecretInput.d.ts +4 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +2 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
- package/dist/react/components/auth/AuthCard.js +5 -1
- package/dist/react/components/auth/AuthCard.js.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.js +2 -2
- package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
- package/dist/react/components/auth/RegisterForm.js +1 -1
- package/dist/react/components/auth/RegisterForm.js.map +1 -1
- package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
- package/dist/react/components/auth/SignupForm.js +4 -2
- package/dist/react/components/auth/SignupForm.js.map +1 -1
- package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
- package/dist/react/components/auth/TotpEntry.js +18 -1
- package/dist/react/components/auth/TotpEntry.js.map +1 -1
- package/dist/react/components/auth/fields.d.ts +3 -1
- package/dist/react/components/auth/fields.d.ts.map +1 -1
- package/dist/react/components/auth/fields.js +6 -2
- package/dist/react/components/auth/fields.js.map +1 -1
- package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
- package/dist/react/components/calendar/DayAgenda.js +4 -2
- package/dist/react/components/calendar/DayAgenda.js.map +1 -1
- package/dist/react/components/calendar/EventChip.d.ts +5 -1
- package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
- package/dist/react/components/calendar/EventChip.js +10 -3
- package/dist/react/components/calendar/EventChip.js.map +1 -1
- package/dist/react/components/calendar/MonthGrid.js +1 -1
- package/dist/react/components/calendar/MonthGrid.js.map +1 -1
- package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
- package/dist/react/components/calendar/WeekView.js +2 -2
- package/dist/react/components/calendar/WeekView.js.map +1 -1
- package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
- package/dist/react/components/calendar/colorUtils.js +13 -11
- package/dist/react/components/calendar/colorUtils.js.map +1 -1
- package/dist/react/components/fieldErrorText.d.ts +12 -0
- package/dist/react/components/fieldErrorText.d.ts.map +1 -0
- package/dist/react/components/fieldErrorText.js +12 -0
- package/dist/react/components/fieldErrorText.js.map +1 -0
- package/dist/react/deep-link/resume.d.ts.map +1 -1
- package/dist/react/deep-link/resume.js +9 -2
- package/dist/react/deep-link/resume.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +3 -0
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +12 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.js +51 -15
- package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
- package/dist/react/file-tree/folderView.d.ts +25 -4
- package/dist/react/file-tree/folderView.d.ts.map +1 -1
- package/dist/react/file-tree/folderView.js +49 -9
- package/dist/react/file-tree/folderView.js.map +1 -1
- package/dist/react/file-tree/index.d.ts +2 -1
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +2 -1
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/file-tree/useFolderView.d.ts +34 -4
- package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
- package/dist/react/file-tree/useFolderView.js +43 -11
- package/dist/react/file-tree/useFolderView.js.map +1 -1
- package/dist/styles-areas/analytics.css +26 -25
- package/dist/styles-areas/calendar.css +4 -4
- package/dist/styles-areas/data-table.css +26 -25
- package/dist/styles-utilities.css +24 -9
- package/package.json +5 -1
- package/scripts/demoCoverage.spec.ts +33 -0
- package/scripts/demoCoverage.ts +124 -0
- package/src/react/charts/IntervalTimeline.tsx +6 -4
- package/src/react/chat/DateMarker.tsx +22 -6
- package/src/react/chat/MessageThread.spec.tsx +25 -1
- package/src/react/components/Button.spec.tsx +11 -0
- package/src/react/components/Button.tsx +5 -0
- package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
- package/src/react/components/DataTable/DataTable.tsx +1 -0
- package/src/react/components/DataTable/SortHeader.tsx +45 -5
- package/src/react/components/DataTable/types.ts +10 -0
- package/src/react/components/DropdownMenu.spec.tsx +26 -0
- package/src/react/components/DropdownMenu.tsx +10 -3
- package/src/react/components/Field.spec.tsx +24 -0
- package/src/react/components/Field.tsx +15 -5
- package/src/react/components/SecretInput.tsx +5 -1
- package/src/react/components/auth/AuthCard.spec.tsx +8 -0
- package/src/react/components/auth/AuthCard.tsx +4 -1
- package/src/react/components/auth/PasswordSetFields.tsx +7 -11
- package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
- package/src/react/components/auth/RegisterForm.tsx +6 -6
- package/src/react/components/auth/SignupForm.spec.tsx +17 -0
- package/src/react/components/auth/SignupForm.tsx +11 -10
- package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
- package/src/react/components/auth/TotpEntry.tsx +18 -1
- package/src/react/components/auth/fields.tsx +8 -1
- package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
- package/src/react/components/calendar/DayAgenda.tsx +5 -2
- package/src/react/components/calendar/EventChip.spec.tsx +12 -8
- package/src/react/components/calendar/EventChip.tsx +11 -6
- package/src/react/components/calendar/MonthGrid.tsx +1 -1
- package/src/react/components/calendar/WeekView.tsx +5 -3
- package/src/react/components/calendar/colorUtils.ts +13 -11
- package/src/react/components/fieldErrorText.tsx +17 -0
- package/src/react/deep-link/resume.spec.tsx +16 -0
- package/src/react/deep-link/resume.ts +9 -2
- package/src/react/file-tree/FileTree.tsx +14 -1
- package/src/react/file-tree/FolderViewMenus.tsx +57 -17
- package/src/react/file-tree/folderView.spec.ts +63 -1
- package/src/react/file-tree/folderView.ts +63 -12
- package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
- package/src/react/file-tree/index.ts +15 -1
- package/src/react/file-tree/useFolderView.ts +73 -12
- package/src/styles-areas/analytics.css +26 -25
- package/src/styles-areas/calendar.css +4 -4
- package/src/styles-areas/data-table.css +26 -25
- package/src/styles-utilities.css +24 -9
|
@@ -72,7 +72,9 @@ export function folderTargets(
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* "Hide
|
|
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.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
*
|
|
17
|
-
* work computer"*, so this is never announced and
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
-
? [...
|
|
138
|
-
:
|
|
139
|
-
return next.length ===
|
|
140
|
-
?
|
|
141
|
-
: { ...
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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';
|