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 and is meaningless for files.
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?.node.name.toLowerCase().startsWith(needle))
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.name.toLowerCase().includes(needle);
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.0",
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 paths && bun run typecheck && bun run build && bun run test",
25
- "prepublishOnly": "bun run verify"
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 and is meaningless for files.
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?.node.name.toLowerCase().startsWith(needle)) return index;
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.name.toLowerCase().includes(needle);
582
+ const hit = drawnName(node).toLowerCase().includes(needle);
536
583
  if (hit) return node;
537
584
  const kids = childrenOf(node)
538
585
  .map(keep)