cursedbelt-core 1.0.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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic behind `react/file-tree` β what is in the tree, what is on screen, and
|
|
3
|
+
* which drops are legal. No DOM, so all of it is unit-tested; the pointer handling, the
|
|
4
|
+
* markers and the keyboard live in the component and are not.
|
|
5
|
+
*
|
|
6
|
+
* ββ Where this came from, and why it is here rather than in an app ββββββββββββββββββββββ
|
|
7
|
+
* Owner, 2026-09-08: *"the spreadsheet app has a much better file tree sidebar than
|
|
8
|
+
* collections has. Add the file tree viewer from it into cb so apps like collections can
|
|
9
|
+
* use it."* `cursed-embeddable-spreadsheet`'s `FileTree` is the tree he means β it has been
|
|
10
|
+
* carrying that app's whole navigation for weeks. What made it good is mostly in this file:
|
|
11
|
+
* a drop that cannot happen is never OFFERED, so there is no marker, no highlight and no
|
|
12
|
+
* error toast after the fact β the cursor simply says no while the row is still in the air.
|
|
13
|
+
*
|
|
14
|
+
* ββ π΄ Why this is not `core/folder-tree` ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
15
|
+
* `folder-tree` derives a hierarchy from delimited PATH STRINGS that items carry, and its
|
|
16
|
+
* nodes are the result of that derivation. This models a tree that already exists, whatever
|
|
17
|
+
* produced it: a node has an id, a kind and children, and an app may hand over folders
|
|
18
|
+
* derived from paths (collections), rows from a table, or a mix of both under one root. The
|
|
19
|
+
* two are complementary β collections feeds `buildFolderTree`'s output straight into this β
|
|
20
|
+
* and collapsing them would force every consumer of one to adopt the other's storage shape.
|
|
21
|
+
*/
|
|
22
|
+
const childrenOf = (node) => node.kind === 'folder' ? (node.children ?? []) : [];
|
|
23
|
+
/** Every node in the tree, depth-first β the order the rows would be in fully expanded. */
|
|
24
|
+
export function walkFileTree(nodes) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const visit = (list) => {
|
|
27
|
+
for (const node of list) {
|
|
28
|
+
out.push(node);
|
|
29
|
+
visit(childrenOf(node));
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
visit(nodes);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function findFileTreeNode(nodes, id) {
|
|
36
|
+
for (const node of walkFileTree(nodes))
|
|
37
|
+
if (node.id === id)
|
|
38
|
+
return node;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The rows actually on screen, given which folders are shut.
|
|
43
|
+
*
|
|
44
|
+
* π΄ `collapsed` is the exception list, not `expanded`. A tree derived fresh from the server
|
|
45
|
+
* on every refresh gets new nodes constantly, and an expanded-set model shows each of them
|
|
46
|
+
* shut β so a newly created folder would appear closed and empty at the moment its contents
|
|
47
|
+
* are the thing you are looking for. The exception list defaults everything open, which is
|
|
48
|
+
* also what reads best for the small trees these sidebars hold.
|
|
49
|
+
*/
|
|
50
|
+
export function flattenFileTree(nodes, collapsed) {
|
|
51
|
+
const shut = new Set(collapsed);
|
|
52
|
+
const out = [];
|
|
53
|
+
const visit = (list, depth, parentId) => {
|
|
54
|
+
list.forEach((node, index) => {
|
|
55
|
+
const kids = childrenOf(node);
|
|
56
|
+
/*
|
|
57
|
+
* π΄ EVERY folder is expandable unless it says otherwise (2026-09-11).
|
|
58
|
+
*
|
|
59
|
+
* Owner: *"Make them use an arrow icon next to a dir even when the dir is empty."*
|
|
60
|
+
* This was `kids.length > 0` and it was wrong in both directions. For a LAZY tree it
|
|
61
|
+
* is a trap door β a consumer that loads a row's contents only while it is open gives
|
|
62
|
+
* a collapsed row no children, the row loses its twisty, and it can never be opened
|
|
63
|
+
* again (measured in `apps/collections`, whose sidebar fetches one folder's files at a
|
|
64
|
+
* time). For an EAGER tree it draws an empty folder as a leaf, so the one mark that
|
|
65
|
+
* distinguishes a place from a file disappears exactly while the folder is new and
|
|
66
|
+
* being filled.
|
|
67
|
+
*
|
|
68
|
+
* "A row that claims to open onto nothing is its own small lie" was the old reasoning
|
|
69
|
+
* and it is answered by what VS Code does: the chevron says *folder*, not *occupied*,
|
|
70
|
+
* and opening an empty one onto nothing is the honest answer, not a broken promise.
|
|
71
|
+
* `node.expandable === false` is still honoured for a row that can never hold anything.
|
|
72
|
+
*/
|
|
73
|
+
const expandable = node.kind === 'folder' && (node.expandable ?? true);
|
|
74
|
+
const expanded = expandable && !shut.has(node.id);
|
|
75
|
+
out.push({ node, depth, parentId, index, expandable, expanded });
|
|
76
|
+
if (expanded)
|
|
77
|
+
visit(kids, depth + 1, node.id);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
visit(nodes, 0, null);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** `id` and everything under it β the set a drag may not land inside. */
|
|
84
|
+
export function descendantIdsOf(nodes, id) {
|
|
85
|
+
const node = findFileTreeNode(nodes, id);
|
|
86
|
+
const out = new Set();
|
|
87
|
+
if (!node)
|
|
88
|
+
return out;
|
|
89
|
+
const visit = (n) => {
|
|
90
|
+
out.add(n.id);
|
|
91
|
+
for (const child of childrenOf(n))
|
|
92
|
+
visit(child);
|
|
93
|
+
};
|
|
94
|
+
visit(node);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The id of the folder a node sits in β `null` at the top level, and `null` for a node this
|
|
99
|
+
* tree does not contain.
|
|
100
|
+
*
|
|
101
|
+
* π΄ The two nulls are deliberately one answer. Every caller is asking "where would a move
|
|
102
|
+
* put this", and for a row the tree no longer holds the honest answer is the top level
|
|
103
|
+
* rather than a throw: a tree re-derived from the server between the press and the release
|
|
104
|
+
* can genuinely lose the row under the pointer.
|
|
105
|
+
*/
|
|
106
|
+
export function parentIdOf(nodes, id) {
|
|
107
|
+
const visit = (list, parent) => {
|
|
108
|
+
for (const node of list) {
|
|
109
|
+
if (node.id === id)
|
|
110
|
+
return { at: parent };
|
|
111
|
+
const found = visit(childrenOf(node), node.id);
|
|
112
|
+
if (found)
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
};
|
|
117
|
+
return visit(nodes, null)?.at ?? null;
|
|
118
|
+
}
|
|
119
|
+
/** The share of a folder row's height that means "into" rather than "between". */
|
|
120
|
+
export const INTO_ZONE = 0.5;
|
|
121
|
+
/**
|
|
122
|
+
* Where the pointer currently is, as a decision.
|
|
123
|
+
*
|
|
124
|
+
* The rule the whole gesture is built around: **a drop that cannot happen is never
|
|
125
|
+
* offered.** Every refusal below returns a reason rather than a null target, so the cursor
|
|
126
|
+
* and the chip can say no while the row is still in the air.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = false, lockedReason = 'That folder is locked', canDrop, }) {
|
|
129
|
+
// Blank space under the last row: out of every folder. The one gesture that needs no
|
|
130
|
+
// target, and the only way to say "top level" when the top level has no row of its own.
|
|
131
|
+
if (!row)
|
|
132
|
+
return canDrop?.(dragId, { kind: 'root' })
|
|
133
|
+
? { target: null, refused: true, reason: canDrop(dragId, { kind: 'root' }) }
|
|
134
|
+
: { target: { kind: 'root' }, refused: false, reason: null };
|
|
135
|
+
const forbidden = dragId ? descendantIdsOf(nodes, dragId) : new Set();
|
|
136
|
+
const target = row.node;
|
|
137
|
+
/** The consumer's last word on an otherwise-legal target. */
|
|
138
|
+
const vetted = (offer) => {
|
|
139
|
+
if (!offer.target || !canDrop)
|
|
140
|
+
return offer;
|
|
141
|
+
const reason = canDrop(dragId, offer.target);
|
|
142
|
+
return reason ? { target: null, refused: true, reason } : offer;
|
|
143
|
+
};
|
|
144
|
+
const intoFolder = () => {
|
|
145
|
+
if (target.kind !== 'folder')
|
|
146
|
+
return { target: null, refused: false, reason: null };
|
|
147
|
+
if (forbidden.has(target.id))
|
|
148
|
+
return {
|
|
149
|
+
target: null,
|
|
150
|
+
refused: true,
|
|
151
|
+
reason: target.id === dragId
|
|
152
|
+
? 'A folder cannot go inside itself'
|
|
153
|
+
: 'A folder cannot go inside one of its own folders',
|
|
154
|
+
};
|
|
155
|
+
if (target.acceptsDrop === false)
|
|
156
|
+
return { target: null, refused: true, reason: lockedReason };
|
|
157
|
+
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
158
|
+
};
|
|
159
|
+
// A FILE row means "the folder that row is in" β what every file explorer does, and what
|
|
160
|
+
// keeps a long list of files from being a wall of rows nothing can be dropped on.
|
|
161
|
+
if (target.kind === 'file' && !allowReorder) {
|
|
162
|
+
if (row.parentId === null)
|
|
163
|
+
return vetted({ target: { kind: 'root' }, refused: false, reason: null });
|
|
164
|
+
const parent = findFileTreeNode(nodes, row.parentId);
|
|
165
|
+
if (parent && parent.acceptsDrop === false)
|
|
166
|
+
return { target: null, refused: true, reason: lockedReason };
|
|
167
|
+
if (parent && forbidden.has(parent.id))
|
|
168
|
+
return { target: null, refused: true, reason: 'A folder cannot go inside itself' };
|
|
169
|
+
return vetted({ target: { kind: 'into', parentId: row.parentId }, refused: false, reason: null });
|
|
170
|
+
}
|
|
171
|
+
if (!allowReorder)
|
|
172
|
+
return intoFolder();
|
|
173
|
+
// With an order to change, the middle half of a folder row is "into" and the edges are
|
|
174
|
+
// "between". A file row is all edges β there is no inside of a file.
|
|
175
|
+
const edge = target.kind === 'folder' ? (1 - INTO_ZONE) / 2 : 0.5;
|
|
176
|
+
if (target.kind === 'folder' && offsetRatio > edge && offsetRatio < 1 - edge)
|
|
177
|
+
return intoFolder();
|
|
178
|
+
const side = offsetRatio < 0.5 ? 'before' : 'after';
|
|
179
|
+
if (forbidden.has(target.id))
|
|
180
|
+
return { target: null, refused: true, reason: 'A folder cannot go inside itself' };
|
|
181
|
+
return vetted({
|
|
182
|
+
target: { kind: side, siblingId: target.id, parentId: row.parentId },
|
|
183
|
+
refused: false,
|
|
184
|
+
reason: null,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Where β₯β / β₯β would put the focused row β the keyboard's half of the drag.
|
|
189
|
+
*
|
|
190
|
+
* π΄ It exists because a gesture with no keyboard route is a gesture half the people using
|
|
191
|
+
* it cannot perform, and because TOUCH has no drag here at all: a sidebar row is a small
|
|
192
|
+
* target on a surface whose one constant gesture is the flick, so the pointer drag gives
|
|
193
|
+
* that up and this is what is left. `cursedesk`'s tree has carried it since it was written;
|
|
194
|
+
* this one shipped with the drag alone.
|
|
195
|
+
*
|
|
196
|
+
* The two directions are the only two a keyboard can mean unambiguously. **β demotes into
|
|
197
|
+
* the nearest folder ABOVE it among its own siblings** β the one place a demotion can go
|
|
198
|
+
* without asking which of several folders was meant β and **β promotes out to the folder its
|
|
199
|
+
* own folder is in**, which is `root` at depth 1.
|
|
200
|
+
*
|
|
201
|
+
* π΄ Every refusal the DRAG honours is honoured here, from the same fields: `acceptsDrop`,
|
|
202
|
+
* the consumer's `canDrop`, and the descendant rule. A keyboard route that can reach a
|
|
203
|
+
* destination the pointer refuses is a second, weaker opinion about what is legal β and it
|
|
204
|
+
* is the one nobody looks at, because the pointer is what gets tested by hand.
|
|
205
|
+
*
|
|
206
|
+
* There is deliberately no β₯β/β₯β here. Those would move a row among its siblings, which is
|
|
207
|
+
* only meaningful when the consumer stores an order β and when it does, the sibling before
|
|
208
|
+
* and after are already reachable as `before`/`after` drops. Offering them unconditionally
|
|
209
|
+
* would write a position nothing reads and then draw the row back where it started.
|
|
210
|
+
*/
|
|
211
|
+
export function resolveKeyboardMove({ nodes, rows, row, direction, lockedReason = 'That folder is locked', canDrop, }) {
|
|
212
|
+
const vetted = (target) => {
|
|
213
|
+
const reason = canDrop?.(row.node.id, target);
|
|
214
|
+
return reason
|
|
215
|
+
? { target: null, refused: true, reason }
|
|
216
|
+
: { target, refused: false, reason: null };
|
|
217
|
+
};
|
|
218
|
+
if (direction === 'out') {
|
|
219
|
+
if (row.parentId === null)
|
|
220
|
+
return { target: null, refused: true, reason: 'It is already at the top level' };
|
|
221
|
+
const grandparentId = parentIdOf(nodes, row.parentId);
|
|
222
|
+
if (grandparentId === null)
|
|
223
|
+
return vetted({ kind: 'root' });
|
|
224
|
+
const grandparent = findFileTreeNode(nodes, grandparentId);
|
|
225
|
+
if (grandparent?.acceptsDrop === false)
|
|
226
|
+
return { target: null, refused: true, reason: lockedReason };
|
|
227
|
+
return vetted({ kind: 'into', parentId: grandparentId });
|
|
228
|
+
}
|
|
229
|
+
// A row's siblings are all on screen whenever the row itself is β a shut folder hides its
|
|
230
|
+
// CHILDREN, never its peers β so the visible rows are the whole level.
|
|
231
|
+
const level = rows.filter((entry) => entry.parentId === row.parentId);
|
|
232
|
+
const above = level
|
|
233
|
+
.filter((entry) => entry.index < row.index && entry.node.kind === 'folder')
|
|
234
|
+
.at(-1);
|
|
235
|
+
if (!above)
|
|
236
|
+
return { target: null, refused: true, reason: 'There is no folder above it to move into' };
|
|
237
|
+
if (above.node.acceptsDrop === false)
|
|
238
|
+
return { target: null, refused: true, reason: lockedReason };
|
|
239
|
+
// Belt and braces: a folder cannot go inside itself, and `above` is a sibling rather than
|
|
240
|
+
// a descendant β but the rule is the tree's, not this function's, and stating it once here
|
|
241
|
+
// is cheaper than discovering the day someone changes what `above` means.
|
|
242
|
+
if (descendantIdsOf(nodes, row.node.id).has(above.node.id))
|
|
243
|
+
return { target: null, refused: true, reason: 'A folder cannot go inside itself' };
|
|
244
|
+
return vetted({ kind: 'into', parentId: above.node.id });
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Where a drop would put the thing being carried, in words β for the chip under the cursor
|
|
248
|
+
* and for the screen reader that cannot see it.
|
|
249
|
+
*
|
|
250
|
+
* π΄ A drag that says only WHAT is held answers half the question. Owner, of the tree this
|
|
251
|
+
* one was ported from: *"dragging an item from one place to another does not have any
|
|
252
|
+
* indication that it is working until you drop it somewhere new and see it move."* The chip
|
|
253
|
+
* following the cursor answers "it is working"; this answers "and it is going there", which
|
|
254
|
+
* is what stops a wrong drop before the pointer is lifted rather than after.
|
|
255
|
+
*
|
|
256
|
+
* Empty for no target β a refusal has its own words and this must never carry a second "no".
|
|
257
|
+
*/
|
|
258
|
+
export function describeDrop(nodes, drop) {
|
|
259
|
+
if (!drop)
|
|
260
|
+
return '';
|
|
261
|
+
if (drop.kind === 'root')
|
|
262
|
+
return 'To the top level';
|
|
263
|
+
if (drop.kind === 'into') {
|
|
264
|
+
const folder = findFileTreeNode(nodes, drop.parentId);
|
|
265
|
+
return folder ? `Into ${folder.name}` : 'Into this folder';
|
|
266
|
+
}
|
|
267
|
+
const sibling = findFileTreeNode(nodes, drop.siblingId);
|
|
268
|
+
const where = drop.kind === 'before' ? 'Above' : 'Below';
|
|
269
|
+
return sibling ? `${where} ${sibling.name}` : `${where} this row`;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The next row whose name starts with `query`, searching from `fromIndex` and wrapping.
|
|
273
|
+
*
|
|
274
|
+
* Typeahead is what makes a long tree usable without a search box β `bud` jumps to
|
|
275
|
+
* `budget-2026` β and wrapping is what stops it dying at the bottom of the list.
|
|
276
|
+
*/
|
|
277
|
+
export function matchTypeahead(rows, query, fromIndex) {
|
|
278
|
+
if (!query)
|
|
279
|
+
return -1;
|
|
280
|
+
const needle = query.toLowerCase();
|
|
281
|
+
for (let step = 1; step <= rows.length; step += 1) {
|
|
282
|
+
const index = (fromIndex + step + rows.length) % rows.length;
|
|
283
|
+
const row = rows[index];
|
|
284
|
+
if (row?.node.name.toLowerCase().startsWith(needle))
|
|
285
|
+
return index;
|
|
286
|
+
}
|
|
287
|
+
return -1;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The tree narrowed to what matches, with the folders on the way to a match kept.
|
|
291
|
+
*
|
|
292
|
+
* A folder that matches keeps ALL of its contents β searching for a folder means you want
|
|
293
|
+
* to see what is in it, not a folder that looks empty because its children spell something
|
|
294
|
+
* else.
|
|
295
|
+
*/
|
|
296
|
+
export function filterFileTree(nodes, query) {
|
|
297
|
+
const needle = query.trim().toLowerCase();
|
|
298
|
+
if (!needle)
|
|
299
|
+
return nodes;
|
|
300
|
+
const keep = (node) => {
|
|
301
|
+
const hit = node.name.toLowerCase().includes(needle);
|
|
302
|
+
if (hit)
|
|
303
|
+
return node;
|
|
304
|
+
const kids = childrenOf(node)
|
|
305
|
+
.map(keep)
|
|
306
|
+
.filter((n) => n !== null);
|
|
307
|
+
if (kids.length === 0)
|
|
308
|
+
return null;
|
|
309
|
+
return { ...node, children: kids };
|
|
310
|
+
};
|
|
311
|
+
return nodes.map(keep).filter((n) => n !== null);
|
|
312
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FOLDER OPERATIONS every app with folders offers β one model, so "move it into
|
|
3
|
+
* that one" means the same thing in music as it does in collections.
|
|
4
|
+
*
|
|
5
|
+
* ββ The owner's ask, verbatim (draft, 2026-08-25) βββββββββββββββββββββββββββββββββββββββ
|
|
6
|
+
* *"in cb make or ensure we have a standardized folder list view. I want it applied to
|
|
7
|
+
* music app and collections app and other apps with folders like that which should get the
|
|
8
|
+
* same features if any. The features should include a 3 dot menu for choosing to move all
|
|
9
|
+
* contents to a different folder or move a folder into another folder. For instance I could
|
|
10
|
+
* move rock songs from a folder 'extra-rock-songs' to a 'rock' folder with it. Also allow
|
|
11
|
+
* deleting a folder which deletes all the folder contents or allows deleting the folder and
|
|
12
|
+
* having the contents not grouped in a folder anymore. Allow renaming folders."*
|
|
13
|
+
*
|
|
14
|
+
* ββ π΄ Four verbs, and three of them already existed ββββββββββββββββββββββββββββββββββββ
|
|
15
|
+
* `planFolderRename`, `planFolderMove` and `planFolderDelete` in `tree.ts` are exactly
|
|
16
|
+
* rename, "move a folder into another folder", and "delete the folder and leave the
|
|
17
|
+
* contents ungrouped" β built for the vault and notes, and reachable from no menu anywhere.
|
|
18
|
+
* The gap the owner is describing is not model, it is SURFACE: every app grew its own
|
|
19
|
+
* subset of folder tools, so the operations that were never wired are the ones nobody has.
|
|
20
|
+
* This module adds the ONE missing verb and the shared descriptions; the menu that renders
|
|
21
|
+
* them is `react/folder-tree/FolderActionsMenu`.
|
|
22
|
+
*
|
|
23
|
+
* ββ π΄ Why "delete the contents too" is not a plan ββββββββββββββββββββββββββββββββββββββ
|
|
24
|
+
* The other three verbs are pure path rewrites β a {@link FolderRemap} list the consumer
|
|
25
|
+
* applies, with nothing destroyed. Deleting the CONTENTS destroys items, and this module
|
|
26
|
+
* has never seen an item; it only ever sees the paths they carry. So `purge` is a mode the
|
|
27
|
+
* menu offers and the CONSUMER implements against its own store, which is also the only
|
|
28
|
+
* place that can count what is about to be lost or refuse the operation. Modelling it here
|
|
29
|
+
* would mean inventing an item, and an item this model cannot see is one it cannot protect.
|
|
30
|
+
*/
|
|
31
|
+
import type { FolderNode, FolderRemap } from "./tree";
|
|
32
|
+
/**
|
|
33
|
+
* Move everything at or under `source` INTO `destination`, and stop `source` existing.
|
|
34
|
+
*
|
|
35
|
+
* The owner's own example: *"I could move rock songs from a folder 'extra-rock-songs' to a
|
|
36
|
+
* 'rock' folder with it."* Items directly in `extra-rock-songs` land directly in `rock`;
|
|
37
|
+
* `extra-rock-songs/live` becomes `rock/live`.
|
|
38
|
+
*
|
|
39
|
+
* π΄ This is NOT `planFolderMove`, and the difference is the whole reason it exists.
|
|
40
|
+
* `planFolderMove(source, newParent)` makes `source` a CHILD β `rock/extra-rock-songs` β
|
|
41
|
+
* which keeps the folder the owner is trying to get rid of. Both are real operations a
|
|
42
|
+
* person wants ("put this inside that" vs "empty this into that"), they read almost
|
|
43
|
+
* identically in a menu, and getting them confused is silent: the files are all present and
|
|
44
|
+
* the folder you meant to dissolve is still there one level down.
|
|
45
|
+
*
|
|
46
|
+
* @param destination an empty array means the ROOT β "take these out of any folder".
|
|
47
|
+
*/
|
|
48
|
+
export declare function planFolderMergeInto(serializedPaths: Iterable<string>, source: readonly string[], destination: readonly string[]): FolderRemap[];
|
|
49
|
+
/** One place a folder may be moved or merged to. `path` empty = the root. */
|
|
50
|
+
export interface FolderTarget {
|
|
51
|
+
path: string[];
|
|
52
|
+
serialized: string;
|
|
53
|
+
/** `Home βΊ Phones`, for a menu label. The root's label is the caller's word for it. */
|
|
54
|
+
label: string;
|
|
55
|
+
key: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Every folder `source` may legally be moved into, deepest-first within each branch.
|
|
59
|
+
*
|
|
60
|
+
* π΄ A folder can never be moved into itself or into one of its own descendants β that
|
|
61
|
+
* orphans the subtree, and `planFolderMove` throws on it. Filtering the DESTINATIONS rather
|
|
62
|
+
* than catching the throw is what makes the menu honest: the offer a person is shown is the
|
|
63
|
+
* set of offers that work, so there is no way to pick one and be told no.
|
|
64
|
+
*
|
|
65
|
+
* The current parent is filtered out too. "Move it to where it already is" is not a
|
|
66
|
+
* no-op a person meant to ask for; it is a menu entry that does nothing, which reads as a
|
|
67
|
+
* broken control.
|
|
68
|
+
*/
|
|
69
|
+
export declare function folderMoveTargets(nodes: readonly FolderNode[], source: readonly string[], options?: {
|
|
70
|
+
rootLabel?: string;
|
|
71
|
+
separator?: string;
|
|
72
|
+
}): FolderTarget[];
|
|
73
|
+
/**
|
|
74
|
+
* What "delete this folder" is allowed to mean.
|
|
75
|
+
*
|
|
76
|
+
* Two modes rather than one, because the owner asked for both in one sentence and they are
|
|
77
|
+
* opposites: one is a filing change and the other destroys files. They are never the same
|
|
78
|
+
* button β see {@link FolderActionsMenuProps} for how the menu keeps them apart.
|
|
79
|
+
*/
|
|
80
|
+
export type FolderDeleteMode =
|
|
81
|
+
/** Splice the folder out; every item keeps existing, one path segment shorter. */
|
|
82
|
+
"ungroup"
|
|
83
|
+
/** Delete the folder AND everything in it. The consumer's store does this, not us. */
|
|
84
|
+
| "purge";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FOLDER OPERATIONS every app with folders offers β one model, so "move it into
|
|
3
|
+
* that one" means the same thing in music as it does in collections.
|
|
4
|
+
*
|
|
5
|
+
* ββ The owner's ask, verbatim (draft, 2026-08-25) βββββββββββββββββββββββββββββββββββββββ
|
|
6
|
+
* *"in cb make or ensure we have a standardized folder list view. I want it applied to
|
|
7
|
+
* music app and collections app and other apps with folders like that which should get the
|
|
8
|
+
* same features if any. The features should include a 3 dot menu for choosing to move all
|
|
9
|
+
* contents to a different folder or move a folder into another folder. For instance I could
|
|
10
|
+
* move rock songs from a folder 'extra-rock-songs' to a 'rock' folder with it. Also allow
|
|
11
|
+
* deleting a folder which deletes all the folder contents or allows deleting the folder and
|
|
12
|
+
* having the contents not grouped in a folder anymore. Allow renaming folders."*
|
|
13
|
+
*
|
|
14
|
+
* ββ π΄ Four verbs, and three of them already existed ββββββββββββββββββββββββββββββββββββ
|
|
15
|
+
* `planFolderRename`, `planFolderMove` and `planFolderDelete` in `tree.ts` are exactly
|
|
16
|
+
* rename, "move a folder into another folder", and "delete the folder and leave the
|
|
17
|
+
* contents ungrouped" β built for the vault and notes, and reachable from no menu anywhere.
|
|
18
|
+
* The gap the owner is describing is not model, it is SURFACE: every app grew its own
|
|
19
|
+
* subset of folder tools, so the operations that were never wired are the ones nobody has.
|
|
20
|
+
* This module adds the ONE missing verb and the shared descriptions; the menu that renders
|
|
21
|
+
* them is `react/folder-tree/FolderActionsMenu`.
|
|
22
|
+
*
|
|
23
|
+
* ββ π΄ Why "delete the contents too" is not a plan ββββββββββββββββββββββββββββββββββββββ
|
|
24
|
+
* The other three verbs are pure path rewrites β a {@link FolderRemap} list the consumer
|
|
25
|
+
* applies, with nothing destroyed. Deleting the CONTENTS destroys items, and this module
|
|
26
|
+
* has never seen an item; it only ever sees the paths they carry. So `purge` is a mode the
|
|
27
|
+
* menu offers and the CONSUMER implements against its own store, which is also the only
|
|
28
|
+
* place that can count what is about to be lost or refuse the operation. Modelling it here
|
|
29
|
+
* would mean inventing an item, and an item this model cannot see is one it cannot protect.
|
|
30
|
+
*/
|
|
31
|
+
import { folderPathKey, isFolderPathUnder, parseFolderPath, serializeFolderPath, } from "./path";
|
|
32
|
+
/**
|
|
33
|
+
* Move everything at or under `source` INTO `destination`, and stop `source` existing.
|
|
34
|
+
*
|
|
35
|
+
* The owner's own example: *"I could move rock songs from a folder 'extra-rock-songs' to a
|
|
36
|
+
* 'rock' folder with it."* Items directly in `extra-rock-songs` land directly in `rock`;
|
|
37
|
+
* `extra-rock-songs/live` becomes `rock/live`.
|
|
38
|
+
*
|
|
39
|
+
* π΄ This is NOT `planFolderMove`, and the difference is the whole reason it exists.
|
|
40
|
+
* `planFolderMove(source, newParent)` makes `source` a CHILD β `rock/extra-rock-songs` β
|
|
41
|
+
* which keeps the folder the owner is trying to get rid of. Both are real operations a
|
|
42
|
+
* person wants ("put this inside that" vs "empty this into that"), they read almost
|
|
43
|
+
* identically in a menu, and getting them confused is silent: the files are all present and
|
|
44
|
+
* the folder you meant to dissolve is still there one level down.
|
|
45
|
+
*
|
|
46
|
+
* @param destination an empty array means the ROOT β "take these out of any folder".
|
|
47
|
+
*/
|
|
48
|
+
export function planFolderMergeInto(serializedPaths, source, destination) {
|
|
49
|
+
if (source.length === 0)
|
|
50
|
+
throw new Error("cannot move the root's contents");
|
|
51
|
+
if (isFolderPathUnder(destination, source)) {
|
|
52
|
+
throw new Error("cannot move a folder's contents into itself or one of its descendants");
|
|
53
|
+
}
|
|
54
|
+
const out = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const raw of serializedPaths) {
|
|
57
|
+
const path = parseFolderPath(raw);
|
|
58
|
+
if (path.length === 0 || !isFolderPathUnder(path, source))
|
|
59
|
+
continue;
|
|
60
|
+
const key = folderPathKey(path);
|
|
61
|
+
if (seen.has(key))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(key);
|
|
64
|
+
// Drop the whole SOURCE prefix and re-root what is left under `destination`.
|
|
65
|
+
const moved = [...destination, ...path.slice(source.length)];
|
|
66
|
+
const from = serializeFolderPath(path);
|
|
67
|
+
const to = serializeFolderPath(moved);
|
|
68
|
+
if (from !== to)
|
|
69
|
+
out.push({ from, to });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Every folder `source` may legally be moved into, deepest-first within each branch.
|
|
75
|
+
*
|
|
76
|
+
* π΄ A folder can never be moved into itself or into one of its own descendants β that
|
|
77
|
+
* orphans the subtree, and `planFolderMove` throws on it. Filtering the DESTINATIONS rather
|
|
78
|
+
* than catching the throw is what makes the menu honest: the offer a person is shown is the
|
|
79
|
+
* set of offers that work, so there is no way to pick one and be told no.
|
|
80
|
+
*
|
|
81
|
+
* The current parent is filtered out too. "Move it to where it already is" is not a
|
|
82
|
+
* no-op a person meant to ask for; it is a menu entry that does nothing, which reads as a
|
|
83
|
+
* broken control.
|
|
84
|
+
*/
|
|
85
|
+
export function folderMoveTargets(nodes, source, options = {}) {
|
|
86
|
+
const separator = options.separator ?? " βΊ ";
|
|
87
|
+
const currentParent = folderPathKey(source.slice(0, -1));
|
|
88
|
+
const targets = [];
|
|
89
|
+
// The root, unless the folder is already at the top level.
|
|
90
|
+
if (source.length > 1) {
|
|
91
|
+
targets.push({
|
|
92
|
+
path: [],
|
|
93
|
+
serialized: "",
|
|
94
|
+
label: options.rootLabel ?? "Top level",
|
|
95
|
+
key: folderPathKey([]),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const walk = (list) => {
|
|
99
|
+
for (const node of list) {
|
|
100
|
+
const key = folderPathKey(node.path);
|
|
101
|
+
const isSelfOrDescendant = isFolderPathUnder(node.path, source);
|
|
102
|
+
if (!isSelfOrDescendant && key !== currentParent) {
|
|
103
|
+
targets.push({
|
|
104
|
+
path: [...node.path],
|
|
105
|
+
serialized: node.serialized,
|
|
106
|
+
label: node.path.join(separator),
|
|
107
|
+
key,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// π΄ Descend even into a branch that was itself refused: only the SUBTREE of
|
|
111
|
+
// `source` is unreachable. The current parent is skipped as a target and its
|
|
112
|
+
// children are perfectly good ones.
|
|
113
|
+
if (!isSelfOrDescendant)
|
|
114
|
+
walk(node.children);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
walk(nodes);
|
|
118
|
+
return targets;
|
|
119
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
|
|
2
|
+
export { buildFolderIndex, buildFolderTree, flattenFolderTree, type FolderIndex, type FolderNode, type FolderRemap, planFolderDelete, planFolderMove, planFolderRename, } from "./tree";
|
|
3
|
+
export { type FolderDeleteMode, type FolderTarget, folderMoveTargets, planFolderMergeInto, } from "./actions";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// cursedbelt-core/folder-tree β the PURE nested-folder model: path encoding, the derived
|
|
2
|
+
// tree, per-folder counts, and the structural-edit plans (rename/move/delete). No
|
|
3
|
+
// React, no DOM, node-builtin-free β so a SERVER can hold the same folder semantics
|
|
4
|
+
// its UI does.
|
|
5
|
+
//
|
|
6
|
+
// import { buildFolderTree, planFolderRename, serializeFolderPath } from 'cursedbelt-core/folder-tree';
|
|
7
|
+
//
|
|
8
|
+
// The three UI pieces (rail, picker, breadcrumb) live at `cursedbelt/react/folder-tree`,
|
|
9
|
+
// which re-exports everything here β so a browser-only consumer keeps one import.
|
|
10
|
+
//
|
|
11
|
+
// ββ Why the model is its own export βββββββββββββββββββββββββββββββββββββββββββββ
|
|
12
|
+
// The vault (the first consumer) is zero-knowledge: folders are derived entirely in
|
|
13
|
+
// the browser from encrypted meta, so a React subpath was the whole world. `apps/notes`
|
|
14
|
+
// is the second, and its folders are SERVER state β filed items are searched, filtered
|
|
15
|
+
// and bulk-moved by SQL. Importing the React barrel into a Bun server to reach
|
|
16
|
+
// `isFolderPathUnder` would drag `Button`/`Input` and the whole component graph into a
|
|
17
|
+
// process that renders nothing. Same law as `cursedbelt-core/sharing` vs `cursedbelt/react/sharing`.
|
|
18
|
+
export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
|
|
19
|
+
export { buildFolderIndex, buildFolderTree, flattenFolderTree, planFolderDelete, planFolderMove, planFolderRename, } from "./tree";
|
|
20
|
+
export { folderMoveTargets, planFolderMergeInto, } from "./actions";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folder PATH encoding β the storage contract every consumer of the folder tree
|
|
3
|
+
* shares. A folder path is an ordered array of segment names (`["Home","Phones",
|
|
4
|
+
* "Noah"]`); on disk it is a single delimiter-joined STRING so it can ride in one
|
|
5
|
+
* field (an entry's `meta.folder`, a note's group tag) without a separate registry.
|
|
6
|
+
*
|
|
7
|
+
* ββ Why a delimited string and not id references βββββββββββββββββββββββββββββ
|
|
8
|
+
* The first consumer (the vault) is a zero-knowledge, op-log-synced store: a folder
|
|
9
|
+
* lives inside an entry's ENCRYPTED meta, and there is no server-side place to keep
|
|
10
|
+
* an idβname map that every device would have to converge on. Deriving the whole
|
|
11
|
+
* tree from the set of path strings on the items keeps folders a pure function of
|
|
12
|
+
* the items β nothing extra to sync, nothing to version-skew. The cost is that a
|
|
13
|
+
* folder exists only while it (or a descendant) holds an item; that is the same
|
|
14
|
+
* "derived, not declared" model the vault already used for flat folders, extended
|
|
15
|
+
* to nesting.
|
|
16
|
+
*
|
|
17
|
+
* ββ The delimiter, and why it is ESCAPED rather than forbidden βββββββββββββββ
|
|
18
|
+
* `/` joins segments. A `/` is common enough inside a real folder name ("TV/Film",
|
|
19
|
+
* "and/or") that forbidding it would be a footgun, and β critically β a MIGRATION
|
|
20
|
+
* hazard: a legacy flat folder literally named "TV/Movies" must stay ONE folder,
|
|
21
|
+
* not silently split into two. So the delimiter is escaped, not banned: a literal
|
|
22
|
+
* `/` in a name becomes `\/` and a literal `\` becomes `\\`. Serializeβparse is a
|
|
23
|
+
* lossless round trip for any name that is non-empty and free of control chars.
|
|
24
|
+
*/
|
|
25
|
+
/** The character that joins path segments in the stored string. */
|
|
26
|
+
export declare const FOLDER_PATH_DELIMITER = "/";
|
|
27
|
+
/** The escape character. A literal delimiter or escape inside a name is prefixed. */
|
|
28
|
+
export declare const FOLDER_PATH_ESCAPE = "\\";
|
|
29
|
+
/** Trim a raw name for use as a segment. Internal whitespace is preserved; only the
|
|
30
|
+
* ends are trimmed, so " My Bank " and "My Bank" are the same folder. */
|
|
31
|
+
export declare function normalizeFolderName(name: string): string;
|
|
32
|
+
/** A folder name is valid iff it is non-empty after trimming and carries no control
|
|
33
|
+
* characters (which would corrupt the stored string and never render). The
|
|
34
|
+
* delimiter and escape are ALLOWED β they are escaped on serialize. */
|
|
35
|
+
export declare function isValidFolderName(name: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Serialize a path (array of segment names) into the stored string. Empty and
|
|
38
|
+
* whitespace-only segments are dropped, and each surviving segment is trimmed, so
|
|
39
|
+
* the output is canonical: `serialize(parse(s))` normalizes any spelling of `s`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function serializeFolderPath(segments: readonly string[]): string;
|
|
42
|
+
/**
|
|
43
|
+
* Parse a stored string back into its segment array. Splits on UNESCAPED delimiters
|
|
44
|
+
* only, then unescapes each segment. Empty segments (a leading/trailing/double
|
|
45
|
+
* delimiter) are dropped rather than becoming blank folders.
|
|
46
|
+
*/
|
|
47
|
+
export declare function parseFolderPath(serialized: string): string[];
|
|
48
|
+
/**
|
|
49
|
+
* A case-insensitive identity key for a path β the thing two paths compare equal by.
|
|
50
|
+
* Folder names dedupe case-insensitively (like the flat rail always did), so "Work"
|
|
51
|
+
* and "work" are one folder, but the key is still per-SEGMENT so "Home/Phones" and
|
|
52
|
+
* "Homework/hones" never collide (which a naive lowercased-string prefix would).
|
|
53
|
+
*/
|
|
54
|
+
export declare function folderPathKey(segments: readonly string[]): string;
|
|
55
|
+
/** True when `child` is `parent` or sits anywhere beneath it (segment-aligned, so
|
|
56
|
+
* `Home` contains `Home/Phones` but NOT `Homework`). An empty `parent` (the root)
|
|
57
|
+
* contains everything. */
|
|
58
|
+
export declare function isFolderPathUnder(child: readonly string[], parent: readonly string[]): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The stored-string prefix EVERY descendant of `segments` starts with, and nothing
|
|
61
|
+
* else does β `serialize(segments) + "/"`. It exists so a store that keeps the
|
|
62
|
+
* serialized path in one column (a SQL `LIKE 'Home/%'`, a `startsWith` on a key/value
|
|
63
|
+
* store) can select a whole subtree without parsing every row.
|
|
64
|
+
*
|
|
65
|
+
* It is exact BECAUSE the delimiter is escaped: a folder literally named `TV/Film`
|
|
66
|
+
* serializes as `TV\/Film`, whose fifth character is the escape, so it can never be
|
|
67
|
+
* mistaken for `TV` + a child. `Homework` does not start with `Home/` either. The one
|
|
68
|
+
* thing the prefix does NOT cover is the folder itself β a caller wants
|
|
69
|
+
* `path = serialized OR path LIKE prefix || '%'`, which is what `isFolderPathUnder`
|
|
70
|
+
* says in memory. The root (an empty path) has no prefix that means "everything", so
|
|
71
|
+
* it returns `""` and the caller should skip the filter entirely.
|
|
72
|
+
*/
|
|
73
|
+
export declare function folderDescendantPrefix(segments: readonly string[]): string;
|
|
74
|
+
/** Render a path as a human breadcrumb: `Home βΊ Phones βΊ Noah`. The separator is a
|
|
75
|
+
* display choice; the default is the one the picker and rail use. */
|
|
76
|
+
export declare function formatFolderPath(segments: readonly string[], separator?: string): string;
|