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, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
90
- * the file, still mean *the folder the file is in* — so a column of such files is never a
91
- * wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
92
- * file row means its folder along its whole height, exactly as before.
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 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) {
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
- const into = vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
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?.node.name.toLowerCase().startsWith(needle))
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.name.toLowerCase().includes(needle);
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.4.0",
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 = (id: string, dragId: string, offsetRatio: number, canDrop?: (d: string | null, t: FileTreeDrop) => string | null) =>
249
- resolveDrop({ nodes, row: rowFor(rows, id), offsetRatio, dragId, ...(canDrop ? { canDrop } : {}) });
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 veto on the file falls back to its folder rather than refusing the row', () => {
261
- const onlySheets = (dragId: string | null, target: FileTreeDrop) =>
262
- target.kind === 'into' && target.parentId === 'q3' && dragId !== 'loose' ? 'Only a sheet can become a tab' : null;
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, and the middle whenever {@link ResolveDropOptions.canDrop} vetoes
93
- * the file, still mean *the folder the file is in* — so a column of such files is never a
94
- * wall of rows that refuse everything that cannot go inside them. Absent or `false`, a
95
- * file row means its folder along its whole height, exactly as before.
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 the drop on the middle of its row —
351
- // see `FileTreeNode.acceptsDrop`. Vetoed, it falls through to meaning its folder below.
352
- if (target.kind === 'file' && !allowReorder && target.acceptsDrop === true && target.id !== dragId) {
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
- const into = vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
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?.node.name.toLowerCase().startsWith(needle)) return index;
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.name.toLowerCase().includes(needle);
601
+ const hit = drawnName(node).toLowerCase().includes(needle);
563
602
  if (hit) return node;
564
603
  const kids = childrenOf(node)
565
604
  .map(keep)