cursedbelt-core 2.3.0 → 2.5.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.
|
@@ -35,6 +35,21 @@ export interface FileTreeNode {
|
|
|
35
35
|
/** Stable across re-derivation — it is the React key, the selection value and the drag id. */
|
|
36
36
|
id: string;
|
|
37
37
|
name: string;
|
|
38
|
+
/**
|
|
39
|
+
* Drawn right after the name, a step quieter — a file's extension, `.md`.
|
|
40
|
+
*
|
|
41
|
+
* For a consumer whose extension is DERIVED rather than part of the stored name
|
|
42
|
+
* (`apps/desk` works a note's suffix out from its format): *"show the extensions in the
|
|
43
|
+
* file tree like vscode does. I need to be able to tell what type each file is"*. Quieter
|
|
44
|
+
* because the name is what you scan FOR and the suffix is what you scan BY.
|
|
45
|
+
*
|
|
46
|
+
* 🔴 It is part of the name everywhere a person types one: {@link matchTypeahead} and
|
|
47
|
+
* {@link filterFileTree} match `name + suffix`, because what is drawn and what can be
|
|
48
|
+
* typed are the same string or they are a bug (*"I typed .json but it said 'Nothing is
|
|
49
|
+
* called that'"*). A rename opens on `name + suffix` with only the name selected — VS
|
|
50
|
+
* Code's field — and hands back whatever was typed, suffix and all.
|
|
51
|
+
*/
|
|
52
|
+
suffix?: string;
|
|
38
53
|
kind: FileTreeNodeKind;
|
|
39
54
|
/** Folders only. A folder with `[]` is empty; a file's is ignored. */
|
|
40
55
|
children?: readonly FileTreeNode[];
|
|
@@ -80,9 +95,26 @@ export interface FileTreeNode {
|
|
|
80
95
|
*
|
|
81
96
|
* The consumer's veto, for a rule the tree cannot know: collections uses it so a folder
|
|
82
97
|
* cannot be dragged across album boundaries, which the server has no route for. Defaults
|
|
83
|
-
* to true for folders
|
|
98
|
+
* to true for folders.
|
|
99
|
+
*
|
|
100
|
+
* 🔴 On a FILE it is the opposite switch, and it is opt-in: `true` makes the file a
|
|
101
|
+
* container of its own — a drop on the middle half of its row is `{ kind: 'into',
|
|
102
|
+
* parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
|
|
103
|
+
* dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
|
|
104
|
+
* The edges of the row, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
|
|
105
|
+
* the file, still mean *the folder the file is in* — so a column of such files is never a
|
|
106
|
+
* wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
|
|
107
|
+
* file row means its folder along its whole height, exactly as before.
|
|
84
108
|
*/
|
|
85
109
|
acceptsDrop?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* This row's own name matched a search the consumer ran — drawn a step heavier.
|
|
112
|
+
*
|
|
113
|
+
* For a consumer that narrows the tree itself (by content, not only by name) and keeps
|
|
114
|
+
* the folders on the way to each hit: the hits are what the eye is scanning for, and the
|
|
115
|
+
* folders kept around them are context. Omit it and nothing is emphasised.
|
|
116
|
+
*/
|
|
117
|
+
matched?: boolean;
|
|
86
118
|
/**
|
|
87
119
|
* Whether this row can be picked up. Default true once `onMove` is wired.
|
|
88
120
|
*
|
|
@@ -283,6 +315,8 @@ export declare function accessMarkCopy(access: FileTreeAccess | undefined, copy?
|
|
|
283
315
|
* Typeahead is what makes a long tree usable without a search box — `bud` jumps to
|
|
284
316
|
* `budget-2026` — and wrapping is what stops it dying at the bottom of the list.
|
|
285
317
|
*/
|
|
318
|
+
/** The name as the row draws it — with its {@link FileTreeNode.suffix}. */
|
|
319
|
+
export declare function drawnName(node: FileTreeNode): string;
|
|
286
320
|
export declare function matchTypeahead(rows: readonly FileTreeRow[], query: string, fromIndex: number): number;
|
|
287
321
|
/**
|
|
288
322
|
* The tree narrowed to what matches, with the folders on the way to a match kept.
|
|
@@ -156,6 +156,16 @@ export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = fa
|
|
|
156
156
|
return { target: null, refused: true, reason: lockedReason };
|
|
157
157
|
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
158
158
|
};
|
|
159
|
+
// A FILE the consumer opted in as a container takes the drop on the middle of its row —
|
|
160
|
+
// see `FileTreeNode.acceptsDrop`. Vetoed, it falls through to meaning its folder below.
|
|
161
|
+
if (target.kind === 'file' && !allowReorder && target.acceptsDrop === true && target.id !== dragId) {
|
|
162
|
+
const band = (1 - INTO_ZONE) / 2;
|
|
163
|
+
if (offsetRatio > band && offsetRatio < 1 - band) {
|
|
164
|
+
const into = vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
165
|
+
if (into.target)
|
|
166
|
+
return into;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
159
169
|
// A FILE row means "the folder that row is in" — what every file explorer does, and what
|
|
160
170
|
// keeps a long list of files from being a wall of rows nothing can be dropped on.
|
|
161
171
|
if (target.kind === 'file' && !allowReorder) {
|
|
@@ -298,6 +308,10 @@ export function accessMarkCopy(access, copy) {
|
|
|
298
308
|
* Typeahead is what makes a long tree usable without a search box — `bud` jumps to
|
|
299
309
|
* `budget-2026` — and wrapping is what stops it dying at the bottom of the list.
|
|
300
310
|
*/
|
|
311
|
+
/** The name as the row draws it — with its {@link FileTreeNode.suffix}. */
|
|
312
|
+
export function drawnName(node) {
|
|
313
|
+
return node.suffix ? `${node.name}${node.suffix}` : node.name;
|
|
314
|
+
}
|
|
301
315
|
export function matchTypeahead(rows, query, fromIndex) {
|
|
302
316
|
if (!query)
|
|
303
317
|
return -1;
|
|
@@ -305,7 +319,7 @@ export function matchTypeahead(rows, query, fromIndex) {
|
|
|
305
319
|
for (let step = 1; step <= rows.length; step += 1) {
|
|
306
320
|
const index = (fromIndex + step + rows.length) % rows.length;
|
|
307
321
|
const row = rows[index];
|
|
308
|
-
if (row
|
|
322
|
+
if (row && drawnName(row.node).toLowerCase().startsWith(needle))
|
|
309
323
|
return index;
|
|
310
324
|
}
|
|
311
325
|
return -1;
|
|
@@ -322,7 +336,7 @@ export function filterFileTree(nodes, query) {
|
|
|
322
336
|
if (!needle)
|
|
323
337
|
return nodes;
|
|
324
338
|
const keep = (node) => {
|
|
325
|
-
const hit = node.
|
|
339
|
+
const hit = drawnName(node).toLowerCase().includes(needle);
|
|
326
340
|
if (hit)
|
|
327
341
|
return node;
|
|
328
342
|
const kids = childrenOf(node)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"clean": "rm -rf dist",
|
|
20
20
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
21
|
-
"test": "NODE_ENV=development bun test src",
|
|
21
|
+
"test": "NODE_ENV=development bun test --timeout 60000 --parallel=4 src",
|
|
22
22
|
"build": "bun run clean && tsc -p tsconfig.build.json",
|
|
23
23
|
"paths": "bun run scripts/paths.ts",
|
|
24
|
-
"verify": "bun run
|
|
25
|
-
"prepublishOnly": "bun run
|
|
24
|
+
"verify": "bun run gate",
|
|
25
|
+
"prepublishOnly": "bun run gate --force",
|
|
26
|
+
"gate": "bun run scripts/paths.ts --only gate.ts"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|
|
28
29
|
"dist",
|
|
@@ -387,5 +388,25 @@
|
|
|
387
388
|
"zod": "4.4.3",
|
|
388
389
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
389
390
|
"hono": "4.12.28"
|
|
391
|
+
},
|
|
392
|
+
"gate": {
|
|
393
|
+
"stages": [
|
|
394
|
+
{
|
|
395
|
+
"script": "paths"
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
"script": "typecheck"
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"script": "build"
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
"script": "test",
|
|
405
|
+
"needs": [
|
|
406
|
+
"build"
|
|
407
|
+
],
|
|
408
|
+
"why": "build starts with `rm -rf dist`, and leafSubpathsImportNothing / shippedFilesAreTracked read dist/; racing it they read a directory that is not there yet"
|
|
409
|
+
}
|
|
410
|
+
]
|
|
390
411
|
}
|
|
391
412
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveDrop,
|
|
14
14
|
resolveKeyboardMove,
|
|
15
15
|
describeDrop,
|
|
16
|
+
drawnName,
|
|
16
17
|
} from './fileTreeModel.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -234,6 +235,49 @@ describe('resolveDrop', () => {
|
|
|
234
235
|
});
|
|
235
236
|
});
|
|
236
237
|
|
|
238
|
+
/**
|
|
239
|
+
* A FILE that takes drops INTO it — `acceptsDrop: true` on a file, which `apps/desk` sets on
|
|
240
|
+
* a workbook so a sheet dropped on it becomes one of its tabs.
|
|
241
|
+
*/
|
|
242
|
+
describe('resolveDrop onto a file that accepts drops', () => {
|
|
243
|
+
const book: FileTreeNode = { id: 'q3', name: 'Q3 Review', kind: 'file', acceptsDrop: true };
|
|
244
|
+
const nodes: FileTreeNode[] = [
|
|
245
|
+
folder('work', 'Work', [book, { id: 'report', name: 'report.csv', kind: 'file' }]),
|
|
246
|
+
{ id: 'loose', name: 'loose.csv', kind: 'file' },
|
|
247
|
+
];
|
|
248
|
+
const rows = flattenFileTree(nodes, []);
|
|
249
|
+
const on = (id: string, dragId: string, offsetRatio: number, canDrop?: (d: string | null, t: FileTreeDrop) => string | null) =>
|
|
250
|
+
resolveDrop({ nodes, row: rowFor(rows, id), offsetRatio, dragId, ...(canDrop ? { canDrop } : {}) });
|
|
251
|
+
|
|
252
|
+
test('the middle of its row is INTO the file', () => {
|
|
253
|
+
expect(on('q3', 'loose', 0.5).target).toEqual({ kind: 'into', parentId: 'q3' });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('its edges still mean the folder it is in', () => {
|
|
257
|
+
expect(on('q3', 'loose', 0.1).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
258
|
+
expect(on('q3', 'loose', 0.9).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('🔴 a veto on the file falls back to its folder rather than refusing the row', () => {
|
|
262
|
+
const onlySheets = (dragId: string | null, target: FileTreeDrop) =>
|
|
263
|
+
target.kind === 'into' && target.parentId === 'q3' && dragId !== 'loose' ? 'Only a sheet can become a tab' : null;
|
|
264
|
+
expect(on('q3', 'report', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
265
|
+
expect(on('q3', 'loose', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'q3' });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('a file never takes itself', () => {
|
|
269
|
+
expect(on('q3', 'q3', 0.5).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('a file WITHOUT the flag means its folder along its whole height, as before', () => {
|
|
273
|
+
expect(on('report', 'loose', 0.5).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('the chip names the file it is going into', () => {
|
|
277
|
+
expect(describeDrop(nodes, { kind: 'into', parentId: 'q3' })).toBe('Into Q3 Review');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
237
281
|
/**
|
|
238
282
|
* 🔴 The consumer's veto, which is the seam collections needs.
|
|
239
283
|
*
|
|
@@ -483,3 +527,25 @@ describe('accessMarkCopy', () => {
|
|
|
483
527
|
expect(accessMarkCopy(undefined)).toBe('');
|
|
484
528
|
});
|
|
485
529
|
});
|
|
530
|
+
|
|
531
|
+
/** `suffix` — a derived extension, drawn and matched as part of the name. */
|
|
532
|
+
describe('a suffix is part of the name a person types', () => {
|
|
533
|
+
const notes: FileTreeNode = { id: 'n', name: 'notes', suffix: '.md', kind: 'file' };
|
|
534
|
+
const nodes: FileTreeNode[] = [folder('docs', 'Docs', [notes]), { id: 'b', name: 'budget', kind: 'file' }];
|
|
535
|
+
|
|
536
|
+
test('drawnName carries it, and a node without one is its name', () => {
|
|
537
|
+
expect(drawnName(notes)).toBe('notes.md');
|
|
538
|
+
expect(drawnName({ id: 'b', name: 'budget', kind: 'file' })).toBe('budget');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('typeahead matches through the dot', () => {
|
|
542
|
+
const rows = flattenFileTree(nodes, []);
|
|
543
|
+
expect(matchTypeahead(rows, 'notes.m', 0)).toBe(rows.findIndex((row) => row.node.id === 'n'));
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('a filter for the extension finds the file', () => {
|
|
547
|
+
const kept = filterFileTree(nodes, '.md');
|
|
548
|
+
expect(kept.map((node) => node.id)).toEqual(['docs']);
|
|
549
|
+
expect((kept[0]?.children ?? []).map((node) => node.id)).toEqual(['n']);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
@@ -38,6 +38,21 @@ export interface FileTreeNode {
|
|
|
38
38
|
/** Stable across re-derivation — it is the React key, the selection value and the drag id. */
|
|
39
39
|
id: string;
|
|
40
40
|
name: string;
|
|
41
|
+
/**
|
|
42
|
+
* Drawn right after the name, a step quieter — a file's extension, `.md`.
|
|
43
|
+
*
|
|
44
|
+
* For a consumer whose extension is DERIVED rather than part of the stored name
|
|
45
|
+
* (`apps/desk` works a note's suffix out from its format): *"show the extensions in the
|
|
46
|
+
* file tree like vscode does. I need to be able to tell what type each file is"*. Quieter
|
|
47
|
+
* because the name is what you scan FOR and the suffix is what you scan BY.
|
|
48
|
+
*
|
|
49
|
+
* 🔴 It is part of the name everywhere a person types one: {@link matchTypeahead} and
|
|
50
|
+
* {@link filterFileTree} match `name + suffix`, because what is drawn and what can be
|
|
51
|
+
* typed are the same string or they are a bug (*"I typed .json but it said 'Nothing is
|
|
52
|
+
* called that'"*). A rename opens on `name + suffix` with only the name selected — VS
|
|
53
|
+
* Code's field — and hands back whatever was typed, suffix and all.
|
|
54
|
+
*/
|
|
55
|
+
suffix?: string;
|
|
41
56
|
kind: FileTreeNodeKind;
|
|
42
57
|
/** Folders only. A folder with `[]` is empty; a file's is ignored. */
|
|
43
58
|
children?: readonly FileTreeNode[];
|
|
@@ -83,9 +98,26 @@ export interface FileTreeNode {
|
|
|
83
98
|
*
|
|
84
99
|
* The consumer's veto, for a rule the tree cannot know: collections uses it so a folder
|
|
85
100
|
* cannot be dragged across album boundaries, which the server has no route for. Defaults
|
|
86
|
-
* to true for folders
|
|
101
|
+
* to true for folders.
|
|
102
|
+
*
|
|
103
|
+
* 🔴 On a FILE it is the opposite switch, and it is opt-in: `true` makes the file a
|
|
104
|
+
* container of its own — a drop on the middle half of its row is `{ kind: 'into',
|
|
105
|
+
* parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
|
|
106
|
+
* dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
|
|
107
|
+
* The edges of the row, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
|
|
108
|
+
* the file, still mean *the folder the file is in* — so a column of such files is never a
|
|
109
|
+
* wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
|
|
110
|
+
* file row means its folder along its whole height, exactly as before.
|
|
87
111
|
*/
|
|
88
112
|
acceptsDrop?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* This row's own name matched a search the consumer ran — drawn a step heavier.
|
|
115
|
+
*
|
|
116
|
+
* For a consumer that narrows the tree itself (by content, not only by name) and keeps
|
|
117
|
+
* the folders on the way to each hit: the hits are what the eye is scanning for, and the
|
|
118
|
+
* folders kept around them are context. Omit it and nothing is emphasised.
|
|
119
|
+
*/
|
|
120
|
+
matched?: boolean;
|
|
89
121
|
/**
|
|
90
122
|
* Whether this row can be picked up. Default true once `onMove` is wired.
|
|
91
123
|
*
|
|
@@ -330,6 +362,16 @@ export function resolveDrop({
|
|
|
330
362
|
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
331
363
|
};
|
|
332
364
|
|
|
365
|
+
// A FILE the consumer opted in as a container takes the drop on the middle of its row —
|
|
366
|
+
// see `FileTreeNode.acceptsDrop`. Vetoed, it falls through to meaning its folder below.
|
|
367
|
+
if (target.kind === 'file' && !allowReorder && target.acceptsDrop === true && target.id !== dragId) {
|
|
368
|
+
const band = (1 - INTO_ZONE) / 2;
|
|
369
|
+
if (offsetRatio > band && offsetRatio < 1 - band) {
|
|
370
|
+
const into = vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
371
|
+
if (into.target) return into;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
333
375
|
// A FILE row means "the folder that row is in" — what every file explorer does, and what
|
|
334
376
|
// keeps a long list of files from being a wall of rows nothing can be dropped on.
|
|
335
377
|
if (target.kind === 'file' && !allowReorder) {
|
|
@@ -503,6 +545,11 @@ export function accessMarkCopy(
|
|
|
503
545
|
* Typeahead is what makes a long tree usable without a search box — `bud` jumps to
|
|
504
546
|
* `budget-2026` — and wrapping is what stops it dying at the bottom of the list.
|
|
505
547
|
*/
|
|
548
|
+
/** The name as the row draws it — with its {@link FileTreeNode.suffix}. */
|
|
549
|
+
export function drawnName(node: FileTreeNode): string {
|
|
550
|
+
return node.suffix ? `${node.name}${node.suffix}` : node.name;
|
|
551
|
+
}
|
|
552
|
+
|
|
506
553
|
export function matchTypeahead(
|
|
507
554
|
rows: readonly FileTreeRow[],
|
|
508
555
|
query: string,
|
|
@@ -513,7 +560,7 @@ export function matchTypeahead(
|
|
|
513
560
|
for (let step = 1; step <= rows.length; step += 1) {
|
|
514
561
|
const index = (fromIndex + step + rows.length) % rows.length;
|
|
515
562
|
const row = rows[index];
|
|
516
|
-
if (row
|
|
563
|
+
if (row && drawnName(row.node).toLowerCase().startsWith(needle)) return index;
|
|
517
564
|
}
|
|
518
565
|
return -1;
|
|
519
566
|
}
|
|
@@ -532,7 +579,7 @@ export function filterFileTree(
|
|
|
532
579
|
const needle = query.trim().toLowerCase();
|
|
533
580
|
if (!needle) return nodes;
|
|
534
581
|
const keep = (node: FileTreeNode): FileTreeNode | null => {
|
|
535
|
-
const hit = node.
|
|
582
|
+
const hit = drawnName(node).toLowerCase().includes(needle);
|
|
536
583
|
if (hit) return node;
|
|
537
584
|
const kids = childrenOf(node)
|
|
538
585
|
.map(keep)
|