cursedbelt-core 2.4.0 → 2.6.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[];
|
|
@@ -86,10 +101,12 @@ export interface FileTreeNode {
|
|
|
86
101
|
* container of its own — a drop on the middle half of its row is `{ kind: 'into',
|
|
87
102
|
* parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
|
|
88
103
|
* dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
|
|
89
|
-
* The edges of the row
|
|
90
|
-
*
|
|
91
|
-
* wall of rows that refuse everything that cannot go inside them.
|
|
92
|
-
*
|
|
104
|
+
* The edges of the row still mean *the folder the file is in*, and so does the middle for
|
|
105
|
+
* any drag {@link ResolveDropOptions.takesDrop} says is not a candidate — so a column of
|
|
106
|
+
* such files is never a wall of rows that refuse everything that cannot go inside them.
|
|
107
|
+
* A candidate that {@link ResolveDropOptions.canDrop} vetoes is REFUSED with its words:
|
|
108
|
+
* the file was the target, and why it cannot take this one has a remedy worth saying.
|
|
109
|
+
* Absent or `false`, a file row means its folder along its whole height, as before.
|
|
93
110
|
*/
|
|
94
111
|
acceptsDrop?: boolean;
|
|
95
112
|
/**
|
|
@@ -218,6 +235,17 @@ export interface ResolveDropOptions {
|
|
|
218
235
|
* this tree is that an impossible drop is never drawn as possible.
|
|
219
236
|
*/
|
|
220
237
|
canDrop?: (dragId: string | null, target: FileTreeDrop) => string | null;
|
|
238
|
+
/**
|
|
239
|
+
* Whether a file that accepts drops (`acceptsDrop: true` on a FILE) is a candidate for
|
|
240
|
+
* THIS drag at all. Default: every drag is.
|
|
241
|
+
*
|
|
242
|
+
* 🔴 This is the question {@link canDrop} cannot answer, because its `null` means "allow":
|
|
243
|
+
* `apps/desk` takes a SHEET into a workbook and never a note, so a note over a workbook
|
|
244
|
+
* must mean the workbook's folder — not a refusal, and not a merge. `false` here is that:
|
|
245
|
+
* the row goes back to meaning its folder. `true` makes the file the target, and then
|
|
246
|
+
* `canDrop` decides, in words — *"“Beta” has 2 sheets"* is a refusal with a remedy.
|
|
247
|
+
*/
|
|
248
|
+
takesDrop?: (dragId: string | null, file: FileTreeNode) => boolean;
|
|
221
249
|
}
|
|
222
250
|
/**
|
|
223
251
|
* Where the pointer currently is, as a decision.
|
|
@@ -226,7 +254,7 @@ export interface ResolveDropOptions {
|
|
|
226
254
|
* offered.** Every refusal below returns a reason rather than a null target, so the cursor
|
|
227
255
|
* and the chip can say no while the row is still in the air.
|
|
228
256
|
*/
|
|
229
|
-
export declare function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder, lockedReason, canDrop, }: ResolveDropOptions): DropOffer;
|
|
257
|
+
export declare function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder, lockedReason, canDrop, takesDrop, }: ResolveDropOptions): DropOffer;
|
|
230
258
|
export interface KeyboardMoveOptions {
|
|
231
259
|
nodes: readonly FileTreeNode[];
|
|
232
260
|
/** The rows on screen — siblings are read off these, which is every sibling by construction. */
|
|
@@ -300,6 +328,8 @@ export declare function accessMarkCopy(access: FileTreeAccess | undefined, copy?
|
|
|
300
328
|
* Typeahead is what makes a long tree usable without a search box — `bud` jumps to
|
|
301
329
|
* `budget-2026` — and wrapping is what stops it dying at the bottom of the list.
|
|
302
330
|
*/
|
|
331
|
+
/** The name as the row draws it — with its {@link FileTreeNode.suffix}. */
|
|
332
|
+
export declare function drawnName(node: FileTreeNode): string;
|
|
303
333
|
export declare function matchTypeahead(rows: readonly FileTreeRow[], query: string, fromIndex: number): number;
|
|
304
334
|
/**
|
|
305
335
|
* The tree narrowed to what matches, with the folders on the way to a match kept.
|
|
@@ -125,7 +125,7 @@ export const INTO_ZONE = 0.5;
|
|
|
125
125
|
* offered.** Every refusal below returns a reason rather than a null target, so the cursor
|
|
126
126
|
* and the chip can say no while the row is still in the air.
|
|
127
127
|
*/
|
|
128
|
-
export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = false, lockedReason = 'That folder is locked', canDrop, }) {
|
|
128
|
+
export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = false, lockedReason = 'That folder is locked', canDrop, takesDrop, }) {
|
|
129
129
|
// Blank space under the last row: out of every folder. The one gesture that needs no
|
|
130
130
|
// target, and the only way to say "top level" when the top level has no row of its own.
|
|
131
131
|
if (!row)
|
|
@@ -156,15 +156,17 @@ 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
|
|
160
|
-
// see `FileTreeNode.acceptsDrop
|
|
161
|
-
|
|
159
|
+
// A FILE the consumer opted in as a container takes a CANDIDATE drag on the middle of its
|
|
160
|
+
// row, vetted with words — see `FileTreeNode.acceptsDrop` and `takesDrop`. Anything else
|
|
161
|
+
// falls through to meaning its folder below.
|
|
162
|
+
if (target.kind === 'file' &&
|
|
163
|
+
!allowReorder &&
|
|
164
|
+
target.acceptsDrop === true &&
|
|
165
|
+
target.id !== dragId &&
|
|
166
|
+
(takesDrop?.(dragId, target) ?? true)) {
|
|
162
167
|
const band = (1 - INTO_ZONE) / 2;
|
|
163
|
-
if (offsetRatio > band && offsetRatio < 1 - band)
|
|
164
|
-
|
|
165
|
-
if (into.target)
|
|
166
|
-
return into;
|
|
167
|
-
}
|
|
168
|
+
if (offsetRatio > band && offsetRatio < 1 - band)
|
|
169
|
+
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
168
170
|
}
|
|
169
171
|
// A FILE row means "the folder that row is in" — what every file explorer does, and what
|
|
170
172
|
// keeps a long list of files from being a wall of rows nothing can be dropped on.
|
|
@@ -308,6 +310,10 @@ export function accessMarkCopy(access, copy) {
|
|
|
308
310
|
* Typeahead is what makes a long tree usable without a search box — `bud` jumps to
|
|
309
311
|
* `budget-2026` — and wrapping is what stops it dying at the bottom of the list.
|
|
310
312
|
*/
|
|
313
|
+
/** The name as the row draws it — with its {@link FileTreeNode.suffix}. */
|
|
314
|
+
export function drawnName(node) {
|
|
315
|
+
return node.suffix ? `${node.name}${node.suffix}` : node.name;
|
|
316
|
+
}
|
|
311
317
|
export function matchTypeahead(rows, query, fromIndex) {
|
|
312
318
|
if (!query)
|
|
313
319
|
return -1;
|
|
@@ -315,7 +321,7 @@ export function matchTypeahead(rows, query, fromIndex) {
|
|
|
315
321
|
for (let step = 1; step <= rows.length; step += 1) {
|
|
316
322
|
const index = (fromIndex + step + rows.length) % rows.length;
|
|
317
323
|
const row = rows[index];
|
|
318
|
-
if (row
|
|
324
|
+
if (row && drawnName(row.node).toLowerCase().startsWith(needle))
|
|
319
325
|
return index;
|
|
320
326
|
}
|
|
321
327
|
return -1;
|
|
@@ -332,7 +338,7 @@ export function filterFileTree(nodes, query) {
|
|
|
332
338
|
if (!needle)
|
|
333
339
|
return nodes;
|
|
334
340
|
const keep = (node) => {
|
|
335
|
-
const hit = node.
|
|
341
|
+
const hit = drawnName(node).toLowerCase().includes(needle);
|
|
336
342
|
if (hit)
|
|
337
343
|
return node;
|
|
338
344
|
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.6.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.",
|
|
@@ -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
|
/**
|
|
@@ -245,8 +246,14 @@ describe('resolveDrop onto a file that accepts drops', () => {
|
|
|
245
246
|
{ id: 'loose', name: 'loose.csv', kind: 'file' },
|
|
246
247
|
];
|
|
247
248
|
const rows = flattenFileTree(nodes, []);
|
|
248
|
-
const on = (
|
|
249
|
-
|
|
249
|
+
const on = (
|
|
250
|
+
id: string,
|
|
251
|
+
dragId: string,
|
|
252
|
+
offsetRatio: number,
|
|
253
|
+
canDrop?: (d: string | null, t: FileTreeDrop) => string | null,
|
|
254
|
+
takesDrop?: (d: string | null, file: FileTreeNode) => boolean,
|
|
255
|
+
) =>
|
|
256
|
+
resolveDrop({ nodes, row: rowFor(rows, id), offsetRatio, dragId, ...(canDrop ? { canDrop } : {}), ...(takesDrop ? { takesDrop } : {}) });
|
|
250
257
|
|
|
251
258
|
test('the middle of its row is INTO the file', () => {
|
|
252
259
|
expect(on('q3', 'loose', 0.5).target).toEqual({ kind: 'into', parentId: 'q3' });
|
|
@@ -257,11 +264,22 @@ describe('resolveDrop onto a file that accepts drops', () => {
|
|
|
257
264
|
expect(on('q3', 'loose', 0.9).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
258
265
|
});
|
|
259
266
|
|
|
260
|
-
test('🔴 a
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
expect(on('q3', 'report', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
264
|
-
expect(on('q3', 'loose', 0.5, onlySheets).target).toEqual({ kind: 'into', parentId: 'q3' });
|
|
267
|
+
test('🔴 a drag that is not a candidate means the folder — not a refusal, not the file', () => {
|
|
268
|
+
// desk: a note over a workbook files it beside the workbook.
|
|
269
|
+
const onlySheets = (dragId: string | null) => dragId === 'loose';
|
|
270
|
+
expect(on('q3', 'report', 0.5, undefined, onlySheets).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
271
|
+
expect(on('q3', 'loose', 0.5, undefined, onlySheets).target).toEqual({ kind: 'into', parentId: 'q3' });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('🔴 a candidate the consumer vetoes is REFUSED, in its words — the file was the target', () => {
|
|
275
|
+
const full = (_dragId: string | null, target: FileTreeDrop) =>
|
|
276
|
+
target.kind === 'into' && target.parentId === 'q3' ? '“loose” has 2 sheets' : null;
|
|
277
|
+
const offer = on('q3', 'loose', 0.5, full);
|
|
278
|
+
expect(offer.target).toBeNull();
|
|
279
|
+
expect(offer.refused).toBe(true);
|
|
280
|
+
expect(offer.reason).toBe('“loose” has 2 sheets');
|
|
281
|
+
// …and the edges still file it beside the workbook.
|
|
282
|
+
expect(on('q3', 'loose', 0.1, full).target).toEqual({ kind: 'into', parentId: 'work' });
|
|
265
283
|
});
|
|
266
284
|
|
|
267
285
|
test('a file never takes itself', () => {
|
|
@@ -526,3 +544,25 @@ describe('accessMarkCopy', () => {
|
|
|
526
544
|
expect(accessMarkCopy(undefined)).toBe('');
|
|
527
545
|
});
|
|
528
546
|
});
|
|
547
|
+
|
|
548
|
+
/** `suffix` — a derived extension, drawn and matched as part of the name. */
|
|
549
|
+
describe('a suffix is part of the name a person types', () => {
|
|
550
|
+
const notes: FileTreeNode = { id: 'n', name: 'notes', suffix: '.md', kind: 'file' };
|
|
551
|
+
const nodes: FileTreeNode[] = [folder('docs', 'Docs', [notes]), { id: 'b', name: 'budget', kind: 'file' }];
|
|
552
|
+
|
|
553
|
+
test('drawnName carries it, and a node without one is its name', () => {
|
|
554
|
+
expect(drawnName(notes)).toBe('notes.md');
|
|
555
|
+
expect(drawnName({ id: 'b', name: 'budget', kind: 'file' })).toBe('budget');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test('typeahead matches through the dot', () => {
|
|
559
|
+
const rows = flattenFileTree(nodes, []);
|
|
560
|
+
expect(matchTypeahead(rows, 'notes.m', 0)).toBe(rows.findIndex((row) => row.node.id === 'n'));
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('a filter for the extension finds the file', () => {
|
|
564
|
+
const kept = filterFileTree(nodes, '.md');
|
|
565
|
+
expect(kept.map((node) => node.id)).toEqual(['docs']);
|
|
566
|
+
expect((kept[0]?.children ?? []).map((node) => node.id)).toEqual(['n']);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
@@ -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[];
|
|
@@ -89,10 +104,12 @@ export interface FileTreeNode {
|
|
|
89
104
|
* container of its own — a drop on the middle half of its row is `{ kind: 'into',
|
|
90
105
|
* parentId: <the file> }`. `apps/desk` is why: a workbook is a leaf in its tree, and
|
|
91
106
|
* dropping a sheet onto one makes the sheet one of its tabs (the owner, desk file `02`).
|
|
92
|
-
* The edges of the row
|
|
93
|
-
*
|
|
94
|
-
* wall of rows that refuse everything that cannot go inside them.
|
|
95
|
-
*
|
|
107
|
+
* The edges of the row still mean *the folder the file is in*, and so does the middle for
|
|
108
|
+
* any drag {@link ResolveDropOptions.takesDrop} says is not a candidate — so a column of
|
|
109
|
+
* such files is never a wall of rows that refuse everything that cannot go inside them.
|
|
110
|
+
* A candidate that {@link ResolveDropOptions.canDrop} vetoes is REFUSED with its words:
|
|
111
|
+
* the file was the target, and why it cannot take this one has a remedy worth saying.
|
|
112
|
+
* Absent or `false`, a file row means its folder along its whole height, as before.
|
|
96
113
|
*/
|
|
97
114
|
acceptsDrop?: boolean;
|
|
98
115
|
/**
|
|
@@ -296,6 +313,17 @@ export interface ResolveDropOptions {
|
|
|
296
313
|
* this tree is that an impossible drop is never drawn as possible.
|
|
297
314
|
*/
|
|
298
315
|
canDrop?: (dragId: string | null, target: FileTreeDrop) => string | null;
|
|
316
|
+
/**
|
|
317
|
+
* Whether a file that accepts drops (`acceptsDrop: true` on a FILE) is a candidate for
|
|
318
|
+
* THIS drag at all. Default: every drag is.
|
|
319
|
+
*
|
|
320
|
+
* 🔴 This is the question {@link canDrop} cannot answer, because its `null` means "allow":
|
|
321
|
+
* `apps/desk` takes a SHEET into a workbook and never a note, so a note over a workbook
|
|
322
|
+
* must mean the workbook's folder — not a refusal, and not a merge. `false` here is that:
|
|
323
|
+
* the row goes back to meaning its folder. `true` makes the file the target, and then
|
|
324
|
+
* `canDrop` decides, in words — *"“Beta” has 2 sheets"* is a refusal with a remedy.
|
|
325
|
+
*/
|
|
326
|
+
takesDrop?: (dragId: string | null, file: FileTreeNode) => boolean;
|
|
299
327
|
}
|
|
300
328
|
|
|
301
329
|
/**
|
|
@@ -313,6 +341,7 @@ export function resolveDrop({
|
|
|
313
341
|
allowReorder = false,
|
|
314
342
|
lockedReason = 'That folder is locked',
|
|
315
343
|
canDrop,
|
|
344
|
+
takesDrop,
|
|
316
345
|
}: ResolveDropOptions): DropOffer {
|
|
317
346
|
// Blank space under the last row: out of every folder. The one gesture that needs no
|
|
318
347
|
// target, and the only way to say "top level" when the top level has no row of its own.
|
|
@@ -347,14 +376,19 @@ export function resolveDrop({
|
|
|
347
376
|
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
348
377
|
};
|
|
349
378
|
|
|
350
|
-
// A FILE the consumer opted in as a container takes
|
|
351
|
-
// see `FileTreeNode.acceptsDrop
|
|
352
|
-
|
|
379
|
+
// A FILE the consumer opted in as a container takes a CANDIDATE drag on the middle of its
|
|
380
|
+
// row, vetted with words — see `FileTreeNode.acceptsDrop` and `takesDrop`. Anything else
|
|
381
|
+
// falls through to meaning its folder below.
|
|
382
|
+
if (
|
|
383
|
+
target.kind === 'file' &&
|
|
384
|
+
!allowReorder &&
|
|
385
|
+
target.acceptsDrop === true &&
|
|
386
|
+
target.id !== dragId &&
|
|
387
|
+
(takesDrop?.(dragId, target) ?? true)
|
|
388
|
+
) {
|
|
353
389
|
const band = (1 - INTO_ZONE) / 2;
|
|
354
|
-
if (offsetRatio > band && offsetRatio < 1 - band)
|
|
355
|
-
|
|
356
|
-
if (into.target) return into;
|
|
357
|
-
}
|
|
390
|
+
if (offsetRatio > band && offsetRatio < 1 - band)
|
|
391
|
+
return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
|
|
358
392
|
}
|
|
359
393
|
|
|
360
394
|
// A FILE row means "the folder that row is in" — what every file explorer does, and what
|
|
@@ -530,6 +564,11 @@ export function accessMarkCopy(
|
|
|
530
564
|
* Typeahead is what makes a long tree usable without a search box — `bud` jumps to
|
|
531
565
|
* `budget-2026` — and wrapping is what stops it dying at the bottom of the list.
|
|
532
566
|
*/
|
|
567
|
+
/** The name as the row draws it — with its {@link FileTreeNode.suffix}. */
|
|
568
|
+
export function drawnName(node: FileTreeNode): string {
|
|
569
|
+
return node.suffix ? `${node.name}${node.suffix}` : node.name;
|
|
570
|
+
}
|
|
571
|
+
|
|
533
572
|
export function matchTypeahead(
|
|
534
573
|
rows: readonly FileTreeRow[],
|
|
535
574
|
query: string,
|
|
@@ -540,7 +579,7 @@ export function matchTypeahead(
|
|
|
540
579
|
for (let step = 1; step <= rows.length; step += 1) {
|
|
541
580
|
const index = (fromIndex + step + rows.length) % rows.length;
|
|
542
581
|
const row = rows[index];
|
|
543
|
-
if (row
|
|
582
|
+
if (row && drawnName(row.node).toLowerCase().startsWith(needle)) return index;
|
|
544
583
|
}
|
|
545
584
|
return -1;
|
|
546
585
|
}
|
|
@@ -559,7 +598,7 @@ export function filterFileTree(
|
|
|
559
598
|
const needle = query.trim().toLowerCase();
|
|
560
599
|
if (!needle) return nodes;
|
|
561
600
|
const keep = (node: FileTreeNode): FileTreeNode | null => {
|
|
562
|
-
const hit = node.
|
|
601
|
+
const hit = drawnName(node).toLowerCase().includes(needle);
|
|
563
602
|
if (hit) return node;
|
|
564
603
|
const kids = childrenOf(node)
|
|
565
604
|
.map(keep)
|