@djangocfg/ui-tools 2.1.415 → 2.1.416

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.
Files changed (110) hide show
  1. package/dist/file-icon/index.d.cts +1 -1
  2. package/dist/file-icon/index.d.ts +1 -1
  3. package/dist/slots-ClRpIzoh.d.cts +88 -0
  4. package/dist/slots-ClRpIzoh.d.ts +88 -0
  5. package/dist/tree/index.cjs +1994 -276
  6. package/dist/tree/index.cjs.map +1 -1
  7. package/dist/tree/index.d.cts +717 -72
  8. package/dist/tree/index.d.ts +717 -72
  9. package/dist/tree/index.mjs +1984 -279
  10. package/dist/tree/index.mjs.map +1 -1
  11. package/package.json +10 -6
  12. package/src/tools/chat/README.md +111 -1
  13. package/src/tools/chat/composer/Composer.tsx +138 -17
  14. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  15. package/src/tools/chat/composer/index.ts +22 -0
  16. package/src/tools/chat/composer/slash/README.md +187 -0
  17. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  18. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  19. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  20. package/src/tools/chat/composer/slash/index.ts +44 -0
  21. package/src/tools/chat/composer/slash/labels.ts +19 -0
  22. package/src/tools/chat/composer/slash/state.ts +168 -0
  23. package/src/tools/chat/composer/slash/types.ts +64 -0
  24. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  25. package/src/tools/chat/composer/types.ts +8 -0
  26. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  27. package/src/tools/chat/shell/index.ts +6 -0
  28. package/src/tools/data/Listbox/lazy.tsx +1 -1
  29. package/src/tools/data/Masonry/lazy.tsx +1 -1
  30. package/src/tools/data/Timeline/lazy.tsx +1 -1
  31. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  32. package/src/tools/data/Tree/README.md +337 -208
  33. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  34. package/src/tools/data/Tree/TreeRoot.tsx +170 -55
  35. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  36. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  37. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  38. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  39. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  40. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  41. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  42. package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
  43. package/src/tools/data/Tree/components/index.ts +6 -0
  44. package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
  45. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  46. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  47. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  48. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  49. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  50. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  51. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  52. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  53. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  54. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  55. package/src/tools/data/Tree/context/hooks.ts +68 -1
  56. package/src/tools/data/Tree/context/index.ts +3 -0
  57. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  58. package/src/tools/data/Tree/context/menu/index.ts +10 -0
  59. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
  60. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  61. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  62. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  63. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  64. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  65. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  66. package/src/tools/data/Tree/context/state/index.ts +6 -0
  67. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  68. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  69. package/src/tools/data/Tree/context/state/types.ts +46 -0
  70. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  71. package/src/tools/data/Tree/data/dnd.ts +123 -0
  72. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  73. package/src/tools/data/Tree/data/index.ts +19 -0
  74. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  75. package/src/tools/data/Tree/data/selection.ts +157 -0
  76. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  77. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  78. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  79. package/src/tools/data/Tree/hooks/index.ts +23 -4
  80. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  81. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  82. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  83. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  84. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  85. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  86. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  87. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  88. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  89. package/src/tools/data/Tree/index.tsx +25 -2
  90. package/src/tools/data/Tree/types/activation.ts +30 -0
  91. package/src/tools/data/Tree/types/adapter.ts +70 -0
  92. package/src/tools/data/Tree/types/index.ts +27 -0
  93. package/src/tools/data/Tree/types/labels.ts +97 -0
  94. package/src/tools/data/Tree/types/loader.ts +9 -0
  95. package/src/tools/data/Tree/types/node.ts +38 -0
  96. package/src/tools/data/Tree/types/root-props.ts +142 -0
  97. package/src/tools/data/Tree/types/selection.ts +3 -0
  98. package/src/tools/data/Tree/types/slots.ts +64 -0
  99. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  100. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  101. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  102. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  103. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  104. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  105. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  106. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  107. package/dist/types-j2vhn4Kv.d.cts +0 -241
  108. package/dist/types-j2vhn4Kv.d.ts +0 -241
  109. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  110. package/src/tools/data/Tree/types.ts +0 -217
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import type { TreeBuiltinAction } from '../types';
4
+
5
+ // =====================================================================
6
+ // Finder / Explorer keyboard shortcuts.
7
+ //
8
+ // Each entry maps a built-in action to a list of `react-hotkeys-hook`
9
+ // combos. `mod` resolves to ⌘ on macOS and Ctrl on Win/Linux automatically,
10
+ // so a single declaration works cross-platform.
11
+ //
12
+ // Tree only binds shortcuts whose corresponding adapter method is
13
+ // defined. So `delete` only fires if `adapter.remove` exists — same
14
+ // gating used for context-menu items, no duplicated source of truth.
15
+ // =====================================================================
16
+
17
+ export interface FinderShortcut {
18
+ /** Built-in action id this shortcut runs. */
19
+ action: TreeBuiltinAction;
20
+ /** `react-hotkeys-hook` key combos. */
21
+ keys: string[];
22
+ /** Cheat-sheet description (registered via `useHotkey`). */
23
+ description: string;
24
+ }
25
+
26
+ export const FINDER_SHORTCUTS: FinderShortcut[] = [
27
+ {
28
+ action: 'delete',
29
+ keys: ['mod+backspace', 'delete'],
30
+ description: 'Delete selected items',
31
+ },
32
+ {
33
+ action: 'rename',
34
+ keys: ['f2'],
35
+ description: 'Rename selected item',
36
+ },
37
+ {
38
+ action: 'duplicate',
39
+ keys: ['mod+d'],
40
+ description: 'Duplicate selected items',
41
+ },
42
+ {
43
+ action: 'new-folder',
44
+ keys: ['mod+shift+n'],
45
+ description: 'New folder',
46
+ },
47
+ {
48
+ action: 'new-file',
49
+ keys: ['mod+n'],
50
+ description: 'New file',
51
+ },
52
+ {
53
+ action: 'cut',
54
+ keys: ['mod+x'],
55
+ description: 'Cut',
56
+ },
57
+ {
58
+ action: 'copy',
59
+ keys: ['mod+c'],
60
+ description: 'Copy',
61
+ },
62
+ {
63
+ action: 'paste',
64
+ keys: ['mod+v'],
65
+ description: 'Paste',
66
+ },
67
+ ];
@@ -8,6 +8,25 @@ export { loadTreeState, saveTreeState, clearTreeState } from './persist';
8
8
  export type { PersistedTreeState } from './persist';
9
9
  export { createDemoTree } from './createDemoTree';
10
10
  export type { DemoNode } from './createDemoTree';
11
+ export {
12
+ computeRange,
13
+ selectionFromClick,
14
+ selectionFromMove,
15
+ selectionSelectAll,
16
+ selectionClear,
17
+ } from './selection';
18
+ export type { SelectionState, ClickModifiers } from './selection';
19
+ export { splitFileName, autoSelectRange } from './renameUtils';
20
+ export type { SplitName } from './renameUtils';
21
+ export { isCutId } from './clipboard';
22
+ export type { ClipboardKind, ClipboardEntry, ClipboardState } from './clipboard';
23
+ export {
24
+ resolveDropZone,
25
+ defaultCanDrop,
26
+ TREE_DND_MIME,
27
+ TREE_ROOT_DROP_ID,
28
+ } from './dnd';
29
+ export type { DropZoneInput, CanDropInput } from './dnd';
11
30
  export {
12
31
  DEFAULT_TREE_APPEARANCE,
13
32
  resolveAppearance,
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ // =====================================================================
4
+ // Filename split — used by the inline-rename input to auto-select the
5
+ // "base" portion without the extension (Finder / macOS behaviour).
6
+ //
7
+ // Rules:
8
+ // - dotfiles like ".env" or ".gitignore" → base = whole name, ext = ''
9
+ // - "foo" → base = 'foo', ext = ''
10
+ // - "foo.txt" → base = 'foo', ext = '.txt'
11
+ // - "foo.bar.baz" → base = 'foo.bar', ext = '.baz'
12
+ // - "folder/" (trailing slash) → trailing slash kept in ext
13
+ //
14
+ // We deliberately don't handle nested dots or multi-part extensions like
15
+ // ".tar.gz". Finder picks the last `.` and so do we — it's good enough
16
+ // for selection UX and consistent with the OS.
17
+ // =====================================================================
18
+
19
+ export interface SplitName {
20
+ base: string;
21
+ ext: string;
22
+ }
23
+
24
+ export function splitFileName(name: string): SplitName {
25
+ if (name.length === 0) return { base: '', ext: '' };
26
+
27
+ // Dotfiles — leading dot is part of the base, not an extension.
28
+ if (name.startsWith('.')) {
29
+ const rest = name.slice(1);
30
+ const dot = rest.lastIndexOf('.');
31
+ if (dot < 0) return { base: name, ext: '' };
32
+ return { base: '.' + rest.slice(0, dot), ext: rest.slice(dot) };
33
+ }
34
+
35
+ const dot = name.lastIndexOf('.');
36
+ if (dot <= 0) return { base: name, ext: '' };
37
+ return { base: name.slice(0, dot), ext: name.slice(dot) };
38
+ }
39
+
40
+ /**
41
+ * Returns the `[selectionStart, selectionEnd]` pair to use on focus of an
42
+ * `<input>` so only the base name is highlighted (Finder behaviour).
43
+ *
44
+ * Folders pass `isFolder=true` to skip extension detection and select
45
+ * the entire name — folders don't have file extensions semantically.
46
+ */
47
+ export function autoSelectRange(name: string, isFolder: boolean): [number, number] {
48
+ if (isFolder) return [0, name.length];
49
+ const { base } = splitFileName(name);
50
+ return [0, base.length];
51
+ }
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow, TreeItemId } from '../types';
4
+
5
+ // =====================================================================
6
+ // Selection helpers — pure functions over flatRows + state.
7
+ //
8
+ // Finder/Explorer rules implemented here:
9
+ // - plain click → replace selection, set anchor = id
10
+ // - meta-click → toggle id, set anchor = id (clicked row becomes new anchor)
11
+ // - shift-click → range [anchor..id] (replaces selection)
12
+ // - shift+meta → union(range [anchor..id], previous selection)
13
+ // - shift+arrows → extend range from anchor through new focused row
14
+ // - mod+a → select all visible rows
15
+ // - escape → clear selection (anchor stays — focused stays)
16
+ // =====================================================================
17
+
18
+ export interface SelectionState {
19
+ selected: ReadonlySet<TreeItemId>;
20
+ /** Anchor for shift-range. `null` when nothing has been selected yet. */
21
+ anchor: TreeItemId | null;
22
+ /** Visual cursor — usually equals the last clicked / arrow-moved row. */
23
+ focused: TreeItemId | null;
24
+ }
25
+
26
+ function indexOf(rows: readonly FlatRow<unknown>[], id: TreeItemId | null): number {
27
+ if (id == null) return -1;
28
+ for (let i = 0; i < rows.length; i++) {
29
+ if (rows[i].node.id === id) return i;
30
+ }
31
+ return -1;
32
+ }
33
+
34
+ /**
35
+ * Inclusive range of ids between `fromId` and `toId` according to their
36
+ * positions in `rows`. Returns an empty array if either endpoint is not in
37
+ * `rows` (e.g. anchor pointed at a row that got collapsed away).
38
+ */
39
+ export function computeRange<T>(
40
+ rows: readonly FlatRow<T>[],
41
+ fromId: TreeItemId | null,
42
+ toId: TreeItemId | null,
43
+ ): TreeItemId[] {
44
+ if (fromId == null || toId == null) return [];
45
+ const a = indexOf(rows, fromId);
46
+ const b = indexOf(rows, toId);
47
+ if (a < 0 || b < 0) return [];
48
+ const [lo, hi] = a <= b ? [a, b] : [b, a];
49
+ const out: TreeItemId[] = [];
50
+ for (let i = lo; i <= hi; i++) out.push(rows[i].node.id);
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Click selection — derives next `{selected, anchor, focused}` from a
56
+ * pointer event's modifier keys. `rows` is the current flat view (used
57
+ * for shift-range computation).
58
+ */
59
+ export interface ClickModifiers {
60
+ shift: boolean;
61
+ meta: boolean;
62
+ }
63
+
64
+ export function selectionFromClick<T>(
65
+ state: SelectionState,
66
+ rows: readonly FlatRow<T>[],
67
+ id: TreeItemId,
68
+ mods: ClickModifiers,
69
+ multi: boolean,
70
+ ): SelectionState {
71
+ // Single-selection mode → always collapse to one id.
72
+ if (!multi) {
73
+ return { selected: new Set([id]), anchor: id, focused: id };
74
+ }
75
+
76
+ // shift+meta → union of current selection with range from anchor to id.
77
+ if (mods.shift && mods.meta) {
78
+ const anchor = state.anchor ?? state.focused ?? id;
79
+ const range = computeRange(rows, anchor, id);
80
+ const next = new Set(state.selected);
81
+ for (const r of range) next.add(r);
82
+ return { selected: next, anchor: state.anchor ?? id, focused: id };
83
+ }
84
+
85
+ // shift-only → replace with range from anchor to id.
86
+ if (mods.shift) {
87
+ const anchor = state.anchor ?? state.focused ?? id;
88
+ const range = computeRange(rows, anchor, id);
89
+ return {
90
+ selected: new Set(range.length > 0 ? range : [id]),
91
+ anchor: state.anchor ?? anchor,
92
+ focused: id,
93
+ };
94
+ }
95
+
96
+ // meta-only → toggle id, reset anchor.
97
+ if (mods.meta) {
98
+ const next = new Set(state.selected);
99
+ if (next.has(id)) next.delete(id);
100
+ else next.add(id);
101
+ return { selected: next, anchor: id, focused: id };
102
+ }
103
+
104
+ // Plain click → replace selection.
105
+ return { selected: new Set([id]), anchor: id, focused: id };
106
+ }
107
+
108
+ /**
109
+ * Move-focus selection — used by arrow keys. When `extend` is true (shift),
110
+ * recompute the range from anchor through the new focused id. Otherwise
111
+ * collapse selection to the new id and reset anchor.
112
+ */
113
+ export function selectionFromMove<T>(
114
+ state: SelectionState,
115
+ rows: readonly FlatRow<T>[],
116
+ nextFocusedId: TreeItemId,
117
+ extend: boolean,
118
+ multi: boolean,
119
+ ): SelectionState {
120
+ if (!multi || !extend) {
121
+ return { selected: new Set([nextFocusedId]), anchor: nextFocusedId, focused: nextFocusedId };
122
+ }
123
+ const anchor = state.anchor ?? state.focused ?? nextFocusedId;
124
+ const range = computeRange(rows, anchor, nextFocusedId);
125
+ return {
126
+ selected: new Set(range.length > 0 ? range : [nextFocusedId]),
127
+ anchor,
128
+ focused: nextFocusedId,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Select every visible row (Cmd/Ctrl+A). Anchor is set to the first row
134
+ * so a subsequent shift-click extends from a predictable starting point.
135
+ */
136
+ export function selectionSelectAll<T>(
137
+ rows: readonly FlatRow<T>[],
138
+ focused: TreeItemId | null,
139
+ ): SelectionState {
140
+ if (rows.length === 0) {
141
+ return { selected: new Set(), anchor: null, focused };
142
+ }
143
+ const ids = rows.map((r) => r.node.id);
144
+ return {
145
+ selected: new Set(ids),
146
+ anchor: ids[0],
147
+ focused: focused ?? ids[0],
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Esc behaviour — clear selected items and anchor, but keep focused so the
153
+ * visual cursor stays where the user left it (Finder mimics this).
154
+ */
155
+ export function selectionClear(state: SelectionState): SelectionState {
156
+ return { selected: new Set(), anchor: null, focused: state.focused };
157
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import type { BuiltinActionContext } from '../../context/menu';
4
+ import type {
5
+ TreeAdapter,
6
+ TreeItemId,
7
+ TreeLabels,
8
+ TreeNode,
9
+ } from '../../types';
10
+
11
+ export interface BuildCtxInput<T> {
12
+ adapter?: TreeAdapter<T>;
13
+ labels: TreeLabels;
14
+ selected: ReadonlySet<TreeItemId>;
15
+ focused: TreeItemId | null;
16
+ getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
17
+ getItemName: (node: TreeNode<T>) => string;
18
+ startInlineRename?: (id: TreeItemId) => void;
19
+ clipboard?: BuiltinActionContext<T>['clipboard'];
20
+ }
21
+
22
+ /**
23
+ * Build a `BuiltinActionContext<T>` snapshot from the current Tree state.
24
+ * Returns `null` when the adapter is missing — the hotkey handlers
25
+ * short-circuit on null so no built-in action fires without an adapter.
26
+ */
27
+ export function buildBuiltinCtx<T>(
28
+ input: BuildCtxInput<T>,
29
+ ): BuiltinActionContext<T> | null {
30
+ if (!input.adapter) return null;
31
+ const selectedNodes: TreeNode<T>[] = [];
32
+ for (const id of input.selected) {
33
+ const n = input.getNodeById(id);
34
+ if (n) selectedNodes.push(n);
35
+ }
36
+ const targetNode = input.focused
37
+ ? input.getNodeById(input.focused) ?? null
38
+ : null;
39
+ return {
40
+ adapter: input.adapter,
41
+ labels: input.labels,
42
+ selectedNodes,
43
+ targetNode,
44
+ getName: input.getItemName,
45
+ startInlineRename: input.startInlineRename,
46
+ clipboard: input.clipboard,
47
+ };
48
+ }
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+
3
+ export { useTreeFinderHotkeys } from './use-tree-finder-hotkeys';
4
+ export type {
5
+ UseTreeFinderHotkeysOptions,
6
+ UseTreeFinderHotkeysReturn,
7
+ } from './use-tree-finder-hotkeys';
8
+ export { buildBuiltinCtx } from './build-ctx';
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
5
+
6
+ import { runBuiltinAction, type BuiltinActionContext } from '../../context/menu';
7
+ import type {
8
+ TreeAdapter,
9
+ TreeBuiltinAction,
10
+ TreeItemId,
11
+ TreeLabels,
12
+ TreeNode,
13
+ } from '../../types';
14
+ import { buildBuiltinCtx } from './build-ctx';
15
+
16
+ export interface UseTreeFinderHotkeysOptions<T> {
17
+ /** Off by default — Tree opt-ins via `enableFinderHotkeys`. */
18
+ enabled: boolean;
19
+ /** Adapter — used both for action availability and dispatch. */
20
+ adapter?: TreeAdapter<T>;
21
+ /** Labels (passed through into adapter action context for dialogs). */
22
+ labels: TreeLabels;
23
+ /** Live selection (set of ids). */
24
+ selected: ReadonlySet<TreeItemId>;
25
+ /** Live focused id (used as "target" for new-file/new-folder actions). */
26
+ focused: TreeItemId | null;
27
+ /** Id → node lookup. */
28
+ getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
29
+ /** Display name resolver. */
30
+ getItemName: (node: TreeNode<T>) => string;
31
+ /** Open inline rename on the row (P3). Falls back to a prompt otherwise. */
32
+ startInlineRename?: (id: TreeItemId) => void;
33
+ /** Clipboard bindings (P5). When undefined, ⌘C/X/V are no-ops. */
34
+ clipboard?: BuiltinActionContext<T>['clipboard'];
35
+ /** Whether typing is currently in inline-rename input — pauses bindings. */
36
+ paused?: boolean;
37
+ }
38
+
39
+ export interface UseTreeFinderHotkeysReturn {
40
+ /** Attach to the tree container ref so hotkeys only fire when it has focus. */
41
+ ref: (instance: HTMLElement | null) => void;
42
+ }
43
+
44
+ /**
45
+ * Wire the platform-aware Finder/Explorer shortcuts to the built-in
46
+ * adapter actions. Bindings are scoped to the container ref via
47
+ * `useHotkey`, so they don't leak to the rest of the page.
48
+ *
49
+ * Each shortcut is bound by an explicit `useHotkey` call (no `.map(useHotkey)`
50
+ * loop, so the rules-of-hooks lint passes cleanly). The handler routes
51
+ * through `runBuiltinAction`, which silently no-ops when the adapter
52
+ * doesn't expose the matching method — so a Tree with `adapter = { remove }`
53
+ * only effectively reacts to ⌘⌫ / Delete.
54
+ */
55
+ export function useTreeFinderHotkeys<T>(
56
+ opts: UseTreeFinderHotkeysOptions<T>,
57
+ ): UseTreeFinderHotkeysReturn {
58
+ const optsRef = useRef(opts);
59
+ optsRef.current = opts;
60
+
61
+ const run = useCallback(async (action: TreeBuiltinAction) => {
62
+ const o = optsRef.current;
63
+ if (o.paused) return;
64
+ const ctx = buildBuiltinCtx({
65
+ adapter: o.adapter,
66
+ labels: o.labels,
67
+ selected: o.selected,
68
+ focused: o.focused,
69
+ getNodeById: o.getNodeById,
70
+ getItemName: o.getItemName,
71
+ startInlineRename: o.startInlineRename,
72
+ clipboard: o.clipboard,
73
+ });
74
+ if (!ctx) return;
75
+ await runBuiltinAction(action, ctx);
76
+ }, []);
77
+
78
+ // One explicit binding per shortcut. Adding a new built-in action means
79
+ // adding one more line here — the trade-off is verbose-but-static vs.
80
+ // a fragile `.map(useHotkey)` loop.
81
+
82
+ const refDelete = useHotkey(
83
+ ['mod+backspace', 'delete'],
84
+ () => void run('delete'),
85
+ {
86
+ enabled: opts.enabled,
87
+ preventDefault: true,
88
+ description: 'Delete selected items',
89
+ scope: 'tree',
90
+ },
91
+ );
92
+
93
+ const refRename = useHotkey('f2', () => void run('rename'), {
94
+ enabled: opts.enabled,
95
+ preventDefault: true,
96
+ description: 'Rename selected item',
97
+ scope: 'tree',
98
+ });
99
+
100
+ const refDuplicate = useHotkey('mod+d', () => void run('duplicate'), {
101
+ enabled: opts.enabled,
102
+ preventDefault: true,
103
+ description: 'Duplicate selected items',
104
+ scope: 'tree',
105
+ });
106
+
107
+ const refNewFolder = useHotkey('mod+shift+n', () => void run('new-folder'), {
108
+ enabled: opts.enabled,
109
+ preventDefault: true,
110
+ description: 'New folder',
111
+ scope: 'tree',
112
+ });
113
+
114
+ const refNewFile = useHotkey('mod+n', () => void run('new-file'), {
115
+ enabled: opts.enabled,
116
+ preventDefault: true,
117
+ description: 'New file',
118
+ scope: 'tree',
119
+ });
120
+
121
+ const refCut = useHotkey('mod+x', () => void run('cut'), {
122
+ enabled: opts.enabled,
123
+ preventDefault: true,
124
+ description: 'Cut',
125
+ scope: 'tree',
126
+ });
127
+
128
+ const refCopy = useHotkey('mod+c', () => void run('copy'), {
129
+ enabled: opts.enabled,
130
+ preventDefault: true,
131
+ description: 'Copy',
132
+ scope: 'tree',
133
+ });
134
+
135
+ const refPaste = useHotkey('mod+v', () => void run('paste'), {
136
+ enabled: opts.enabled,
137
+ preventDefault: true,
138
+ description: 'Paste',
139
+ scope: 'tree',
140
+ });
141
+
142
+ const ref = useCallback(
143
+ (instance: HTMLElement | null) => {
144
+ refDelete(instance);
145
+ refRename(instance);
146
+ refDuplicate(instance);
147
+ refNewFolder(instance);
148
+ refNewFile(instance);
149
+ refCut(instance);
150
+ refCopy(instance);
151
+ refPaste(instance);
152
+ },
153
+ [
154
+ refDelete,
155
+ refRename,
156
+ refDuplicate,
157
+ refNewFolder,
158
+ refNewFile,
159
+ refCut,
160
+ refCopy,
161
+ refPaste,
162
+ ],
163
+ );
164
+
165
+ return { ref };
166
+ }
@@ -1,6 +1,25 @@
1
1
  'use client';
2
2
 
3
- export { useTreeTypeAhead } from './useTreeTypeAhead';
4
- export type { UseTreeTypeAheadOptions } from './useTreeTypeAhead';
5
- export { useTreeKeyboard } from './useTreeKeyboard';
6
- export type { UseTreeKeyboardOptions } from './useTreeKeyboard';
3
+ // Public surface re-exports from each feature folder.
4
+ //
5
+ // Each folder owns one "scope" of keyboard interaction:
6
+ // - keyboard/ arrow / home / end / enter / esc / mod+a
7
+ // - type-ahead/ printable-keys rolling buffer
8
+ // - finder-hotkeys/ Finder/Explorer shortcuts wired to adapter actions
9
+ //
10
+ // Adding a new scope = adding a new folder, not editing this barrel.
11
+
12
+ export { useTreeKeyboard } from './keyboard';
13
+ export type {
14
+ UseTreeKeyboardOptions,
15
+ UseTreeKeyboardReturn,
16
+ } from './keyboard';
17
+
18
+ export { useTreeTypeAhead } from './type-ahead';
19
+ export type { UseTreeTypeAheadOptions } from './type-ahead';
20
+
21
+ export { useTreeFinderHotkeys } from './finder-hotkeys';
22
+ export type {
23
+ UseTreeFinderHotkeysOptions,
24
+ UseTreeFinderHotkeysReturn,
25
+ } from './finder-hotkeys';
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow } from '../../types';
4
+
5
+ /**
6
+ * Enter / Space on the current row:
7
+ * - folder: toggle (collapse if expanded, else expand)
8
+ * - leaf: activate
9
+ *
10
+ * Always selects (single-select semantics for keyboard input).
11
+ */
12
+ export type ActivateOutcome<T> =
13
+ | { kind: 'toggle-folder'; id: string; willExpand: boolean }
14
+ | { kind: 'activate-leaf'; id: string }
15
+ | { kind: 'noop' };
16
+
17
+ export function resolveActivate<T>(current: FlatRow<T> | null): ActivateOutcome<T> {
18
+ if (!current) return { kind: 'noop' };
19
+ if (current.isFolder) {
20
+ return {
21
+ kind: 'toggle-folder',
22
+ id: current.node.id,
23
+ willExpand: !current.isExpanded,
24
+ };
25
+ }
26
+ return { kind: 'activate-leaf', id: current.node.id };
27
+ }
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow, TreeItemId } from '../../types';
4
+
5
+ /** Pick the id one row below the current focused row (clamped at the end). */
6
+ export function nextRowId<T>(rows: readonly FlatRow<T>[], idx: number): TreeItemId | null {
7
+ if (rows.length === 0) return null;
8
+ const next = rows[Math.min(idx + 1, rows.length - 1)] ?? rows[0];
9
+ return next.node.id;
10
+ }
11
+
12
+ /** Pick the id one row above the current focused row (clamped at the start). */
13
+ export function prevRowId<T>(rows: readonly FlatRow<T>[], idx: number): TreeItemId | null {
14
+ if (rows.length === 0) return null;
15
+ const prev = rows[Math.max(idx - 1, 0)] ?? rows[0];
16
+ return prev.node.id;
17
+ }
18
+
19
+ /** First / last visible row id, or null if the list is empty. */
20
+ export function edgeRowId<T>(
21
+ rows: readonly FlatRow<T>[],
22
+ edge: 'first' | 'last',
23
+ ): TreeItemId | null {
24
+ if (rows.length === 0) return null;
25
+ return edge === 'first' ? rows[0].node.id : rows[rows.length - 1].node.id;
26
+ }
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow } from '../../types';
4
+
5
+ /**
6
+ * What should `→` do on the current row?
7
+ *
8
+ * - collapsed folder → expand it
9
+ * - expanded folder → jump to first visible child
10
+ * - leaf → no-op
11
+ */
12
+ export type RightArrowOutcome<T> =
13
+ | { kind: 'expand'; id: string }
14
+ | { kind: 'focus'; id: string }
15
+ | { kind: 'noop' };
16
+
17
+ export function resolveRightArrow<T>(
18
+ current: FlatRow<T> | null,
19
+ rows: readonly FlatRow<T>[],
20
+ idx: number,
21
+ ): RightArrowOutcome<T> {
22
+ if (!current) return { kind: 'noop' };
23
+ if (current.isFolder && !current.isExpanded) {
24
+ return { kind: 'expand', id: current.node.id };
25
+ }
26
+ if (current.isFolder && current.isExpanded) {
27
+ const next = rows[idx + 1];
28
+ return next ? { kind: 'focus', id: next.node.id } : { kind: 'noop' };
29
+ }
30
+ return { kind: 'noop' };
31
+ }
32
+
33
+ /**
34
+ * What should `←` do on the current row?
35
+ *
36
+ * - expanded folder → collapse it
37
+ * - leaf / collapsed w/ parent → focus parent
38
+ * - root leaf → no-op
39
+ */
40
+ export type LeftArrowOutcome<T> =
41
+ | { kind: 'collapse'; id: string }
42
+ | { kind: 'focus'; id: string }
43
+ | { kind: 'noop' };
44
+
45
+ export function resolveLeftArrow<T>(current: FlatRow<T> | null): LeftArrowOutcome<T> {
46
+ if (!current) return { kind: 'noop' };
47
+ if (current.isFolder && current.isExpanded) {
48
+ return { kind: 'collapse', id: current.node.id };
49
+ }
50
+ if (current.parentId) {
51
+ return { kind: 'focus', id: current.parentId };
52
+ }
53
+ return { kind: 'noop' };
54
+ }
@@ -0,0 +1,10 @@
1
+ 'use client';
2
+
3
+ export { useTreeKeyboard } from './use-tree-keyboard';
4
+ export type {
5
+ UseTreeKeyboardOptions,
6
+ UseTreeKeyboardReturn,
7
+ } from './types';
8
+ export { nextRowId, prevRowId, edgeRowId } from './arrow-nav';
9
+ export { resolveLeftArrow, resolveRightArrow } from './expand-collapse';
10
+ export { resolveActivate } from './activation';