@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,55 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+
5
+ import type { Action } from '../state';
6
+ import type { TreeItemId } from '../../types';
7
+
8
+ export interface UseExpansionOptions {
9
+ dispatch: React.Dispatch<Action>;
10
+ /** Build the "all folder ids" list — usually wired to `useAsyncChildren`. */
11
+ collectFolderIds: () => TreeItemId[];
12
+ }
13
+
14
+ export interface UseExpansionReturn {
15
+ expand: (id: TreeItemId) => void;
16
+ collapse: (id: TreeItemId) => void;
17
+ toggle: (id: TreeItemId) => void;
18
+ expandAll: () => void;
19
+ collapseAll: () => void;
20
+ }
21
+
22
+ /**
23
+ * Thin action wrappers around the reducer's expansion actions, plus
24
+ * `expandAll` / `collapseAll`. `expandAll` walks roots and cached async
25
+ * children so partially-loaded trees expand everything they currently
26
+ * know about.
27
+ */
28
+ export function useExpansion({
29
+ dispatch,
30
+ collectFolderIds,
31
+ }: UseExpansionOptions): UseExpansionReturn {
32
+ const expand = useCallback(
33
+ (id: TreeItemId) => dispatch({ type: 'expand', id }),
34
+ [dispatch],
35
+ );
36
+ const collapse = useCallback(
37
+ (id: TreeItemId) => dispatch({ type: 'collapse', id }),
38
+ [dispatch],
39
+ );
40
+ const toggle = useCallback(
41
+ (id: TreeItemId) => dispatch({ type: 'toggle', id }),
42
+ [dispatch],
43
+ );
44
+
45
+ const expandAll = useCallback(() => {
46
+ dispatch({ type: 'set-expanded', ids: collectFolderIds() });
47
+ }, [dispatch, collectFolderIds]);
48
+
49
+ const collapseAll = useCallback(
50
+ () => dispatch({ type: 'set-expanded', ids: [] }),
51
+ [dispatch],
52
+ );
53
+
54
+ return { expand, collapse, toggle, expandAll, collapseAll };
55
+ }
@@ -23,12 +23,26 @@ export function useTreeSelection<T>() {
23
23
  return useMemo(
24
24
  () => ({
25
25
  selectedIds,
26
+ anchor: ctx.anchor,
26
27
  select: ctx.select,
27
28
  setSelectedIds: ctx.setSelectedIds,
28
29
  clear: ctx.clearSelection,
30
+ clickSelect: ctx.clickSelect,
31
+ moveSelect: ctx.moveSelect,
32
+ selectAll: ctx.selectAll,
29
33
  isSelected,
30
34
  }),
31
- [selectedIds, ctx.select, ctx.setSelectedIds, ctx.clearSelection, isSelected],
35
+ [
36
+ selectedIds,
37
+ ctx.anchor,
38
+ ctx.select,
39
+ ctx.setSelectedIds,
40
+ ctx.clearSelection,
41
+ ctx.clickSelect,
42
+ ctx.moveSelect,
43
+ ctx.selectAll,
44
+ isSelected,
45
+ ],
32
46
  );
33
47
  }
34
48
 
@@ -83,6 +97,59 @@ export function useTreeSearch<T>() {
83
97
  );
84
98
  }
85
99
 
100
+ export function useTreeDnd<T>() {
101
+ const ctx = useTreeContext<T>();
102
+ return ctx.dnd;
103
+ }
104
+
105
+ export function useTreeClipboard<T>() {
106
+ const ctx = useTreeContext<T>();
107
+ const isCut = useCallback(
108
+ (id: TreeItemId) => ctx.clipboard?.kind === 'cut' && ctx.clipboard.ids.includes(id),
109
+ [ctx.clipboard],
110
+ );
111
+ return useMemo(
112
+ () => ({
113
+ clipboard: ctx.clipboard,
114
+ isCut,
115
+ cut: ctx.cutToClipboard,
116
+ copy: ctx.copyToClipboard,
117
+ paste: ctx.pasteFromClipboard,
118
+ clear: ctx.clearClipboard,
119
+ }),
120
+ [
121
+ ctx.clipboard,
122
+ isCut,
123
+ ctx.cutToClipboard,
124
+ ctx.copyToClipboard,
125
+ ctx.pasteFromClipboard,
126
+ ctx.clearClipboard,
127
+ ],
128
+ );
129
+ }
130
+
131
+ export function useTreeRename<T>() {
132
+ const ctx = useTreeContext<T>();
133
+ return useMemo(
134
+ () => ({
135
+ /** True when the host allowed inline rename AND the adapter exposes `rename`. */
136
+ enabled: ctx.inlineRenameEnabled,
137
+ /** Currently renaming id, or `null`. */
138
+ renamingId: ctx.renamingId,
139
+ startRename: ctx.startRename,
140
+ cancelRename: ctx.cancelRename,
141
+ commitRename: ctx.commitRename,
142
+ }),
143
+ [
144
+ ctx.inlineRenameEnabled,
145
+ ctx.renamingId,
146
+ ctx.startRename,
147
+ ctx.cancelRename,
148
+ ctx.commitRename,
149
+ ],
150
+ );
151
+ }
152
+
86
153
  export function useTreeActions<T>() {
87
154
  const ctx = useTreeContext<T>();
88
155
  return useMemo(
@@ -9,5 +9,8 @@ export {
9
9
  useTreeExpansion,
10
10
  useTreeFocus,
11
11
  useTreeSearch,
12
+ useTreeRename,
13
+ useTreeClipboard,
14
+ useTreeDnd,
12
15
  useTreeActions,
13
16
  } from './hooks';
@@ -0,0 +1,357 @@
1
+ 'use client';
2
+
3
+ import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
4
+ import {
5
+ Copy,
6
+ CornerUpLeft,
7
+ FilePlus,
8
+ FolderPlus,
9
+ Pencil,
10
+ Scissors,
11
+ Trash2,
12
+ } from 'lucide-react';
13
+ import type { ComponentType } from 'react';
14
+
15
+ import type {
16
+ TreeAdapter,
17
+ TreeBuiltinAction,
18
+ TreeContextMenuItem,
19
+ TreeItemId,
20
+ TreeLabels,
21
+ TreeNode,
22
+ TreeRowRenderProps,
23
+ } from '../../types';
24
+
25
+ // =====================================================================
26
+ // BuiltinActionContext — everything the action handler needs from Tree.
27
+ // Kept as a plain object (not a React context) so the same code can be
28
+ // shared by hotkeys, right-click menus, and the empty-area menu.
29
+ // =====================================================================
30
+
31
+ export interface BuiltinActionContext<T> {
32
+ adapter: TreeAdapter<T>;
33
+ labels: TreeLabels;
34
+ /** Currently selected nodes (full objects, resolved from ids). */
35
+ selectedNodes: TreeNode<T>[];
36
+ /** Row the user right-clicked / triggered the action on. May be null
37
+ * for empty-area actions (paste / new file / new folder at root). */
38
+ targetNode: TreeNode<T> | null;
39
+ /** Returns the human-readable name for a node (uses `getItemName`). */
40
+ getName: (node: TreeNode<T>) => string;
41
+ /** Imperative: start inline rename on this id (no-op if disabled). */
42
+ startInlineRename?: (id: TreeItemId) => void;
43
+ /** Clipboard hooks (P5). Provided by Tree's context; pure forwarding. */
44
+ clipboard?: {
45
+ /** Current clipboard kind, if any (so we can hide "Paste" when empty). */
46
+ hasItems: boolean;
47
+ cut: (ids: TreeItemId[]) => void;
48
+ copy: (ids: TreeItemId[]) => void;
49
+ paste: () => void | Promise<void>;
50
+ };
51
+ }
52
+
53
+ interface BuiltinActionDescriptor<T> {
54
+ id: TreeBuiltinAction;
55
+ label: (ctx: BuiltinActionContext<T>) => string;
56
+ icon: ComponentType<{ className?: string }>;
57
+ destructive?: boolean;
58
+ shortcut?: string;
59
+ /** Should this row appear in the menu given the current adapter + selection? */
60
+ available: (ctx: BuiltinActionContext<T>) => boolean;
61
+ run: (ctx: BuiltinActionContext<T>) => Promise<void> | void;
62
+ }
63
+
64
+ // =====================================================================
65
+ // Dialog helpers — thin wrappers that bail out gracefully if
66
+ // <DialogProvider /> isn't mounted. Tree should still render in that
67
+ // case, just without CRUD flows.
68
+ // =====================================================================
69
+
70
+ async function confirmDelete<T>(ctx: BuiltinActionContext<T>): Promise<boolean> {
71
+ const dialog = getDialog();
72
+ if (!dialog) return false;
73
+ const { selectedNodes, labels, getName } = ctx;
74
+ return dialog.confirm({
75
+ title: labels.confirmDeleteTitle(selectedNodes.length),
76
+ message: labels.confirmDeleteMessage(selectedNodes.map(getName)),
77
+ confirmText: labels.confirmDeleteOk,
78
+ cancelText: labels.confirmDeleteCancel,
79
+ variant: 'destructive',
80
+ });
81
+ }
82
+
83
+ async function promptName<T>(
84
+ ctx: BuiltinActionContext<T>,
85
+ spec: {
86
+ title: string;
87
+ message: string;
88
+ placeholder: string;
89
+ defaultValue: string;
90
+ },
91
+ ): Promise<string | null> {
92
+ const dialog = getDialog();
93
+ if (!dialog) return null;
94
+ return dialog.prompt({
95
+ title: spec.title,
96
+ message: spec.message,
97
+ placeholder: spec.placeholder,
98
+ defaultValue: spec.defaultValue,
99
+ });
100
+ }
101
+
102
+ async function alertError<T>(
103
+ ctx: BuiltinActionContext<T>,
104
+ message: string,
105
+ ): Promise<void> {
106
+ const dialog = getDialog();
107
+ if (!dialog) return;
108
+ await dialog.alert({ message, title: ctx.labels.error });
109
+ }
110
+
111
+ /**
112
+ * Validate a name through `adapter.validateName` and a tiny built-in
113
+ * non-empty check. Returns `null` if valid, otherwise the error message
114
+ * to surface via `dialog.alert`.
115
+ */
116
+ function validateName<T>(
117
+ ctx: BuiltinActionContext<T>,
118
+ name: string,
119
+ validateCtx: { node?: TreeNode<T>; parent?: TreeNode<T> | null },
120
+ ): string | null {
121
+ if (name.trim() === '') return ctx.labels.invalidNameEmpty;
122
+ return ctx.adapter.validateName?.(name, validateCtx) ?? null;
123
+ }
124
+
125
+ // =====================================================================
126
+ // Built-in action descriptors. Order matters — it controls menu order.
127
+ // =====================================================================
128
+
129
+ const BUILTIN_ACTIONS: BuiltinActionDescriptor<unknown>[] = [
130
+ {
131
+ id: 'open',
132
+ label: (ctx) => ctx.labels.actionOpen,
133
+ icon: CornerUpLeft,
134
+ available: () => false, // wired by Tree on activate; not in menu by default
135
+ run: () => {},
136
+ },
137
+ {
138
+ id: 'rename',
139
+ label: (ctx) => ctx.labels.actionRename,
140
+ icon: Pencil,
141
+ shortcut: 'F2',
142
+ available: (ctx) =>
143
+ !!ctx.adapter.rename &&
144
+ ctx.selectedNodes.length === 1 &&
145
+ !ctx.selectedNodes[0].disabled,
146
+ run: async (ctx) => {
147
+ // Prefer inline rename when Tree can drive it; fall back to a prompt.
148
+ const node = ctx.selectedNodes[0];
149
+ if (ctx.startInlineRename) {
150
+ ctx.startInlineRename(node.id);
151
+ return;
152
+ }
153
+ const name = await promptName(ctx, {
154
+ title: ctx.labels.renameTitle,
155
+ message: ctx.labels.renameMessage,
156
+ placeholder: ctx.getName(node),
157
+ defaultValue: ctx.getName(node),
158
+ });
159
+ if (name === null) return;
160
+ const err = validateName(ctx, name, { node });
161
+ if (err) return alertError(ctx, err);
162
+ await ctx.adapter.rename!(node, name);
163
+ },
164
+ },
165
+ {
166
+ id: 'duplicate',
167
+ label: (ctx) => ctx.labels.actionDuplicate,
168
+ icon: Copy,
169
+ shortcut: '⌘D',
170
+ available: (ctx) => !!ctx.adapter.duplicate && ctx.selectedNodes.length > 0,
171
+ run: (ctx) => ctx.adapter.duplicate!(ctx.selectedNodes),
172
+ },
173
+ {
174
+ id: 'cut',
175
+ label: (ctx) => ctx.labels.actionCut,
176
+ icon: Scissors,
177
+ shortcut: '⌘X',
178
+ // Only meaningful when the adapter supports `move` (paste-after-cut)
179
+ // AND Tree provided a clipboard binding.
180
+ available: (ctx) =>
181
+ !!ctx.adapter.move && !!ctx.clipboard && ctx.selectedNodes.length > 0,
182
+ run: (ctx) => {
183
+ ctx.clipboard?.cut(ctx.selectedNodes.map((n) => n.id));
184
+ },
185
+ },
186
+ {
187
+ id: 'copy',
188
+ label: (ctx) => ctx.labels.actionCopy,
189
+ icon: Copy,
190
+ shortcut: '⌘C',
191
+ available: (ctx) =>
192
+ !!ctx.adapter.copy && !!ctx.clipboard && ctx.selectedNodes.length > 0,
193
+ run: (ctx) => {
194
+ ctx.clipboard?.copy(ctx.selectedNodes.map((n) => n.id));
195
+ },
196
+ },
197
+ {
198
+ id: 'paste',
199
+ label: (ctx) => ctx.labels.actionPaste,
200
+ icon: CornerUpLeft,
201
+ shortcut: '⌘V',
202
+ available: (ctx) => !!ctx.clipboard?.hasItems,
203
+ run: async (ctx) => {
204
+ await ctx.clipboard?.paste();
205
+ },
206
+ },
207
+ {
208
+ id: 'delete',
209
+ label: (ctx) => ctx.labels.actionDelete,
210
+ icon: Trash2,
211
+ shortcut: '⌘⌫',
212
+ destructive: true,
213
+ available: (ctx) => !!ctx.adapter.remove && ctx.selectedNodes.length > 0,
214
+ run: async (ctx) => {
215
+ const ok = await confirmDelete(ctx);
216
+ if (!ok) return;
217
+ await ctx.adapter.remove!(ctx.selectedNodes);
218
+ },
219
+ },
220
+ {
221
+ id: 'new-file',
222
+ label: (ctx) => ctx.labels.actionNewFile,
223
+ icon: FilePlus,
224
+ available: (ctx) => !!ctx.adapter.createFile,
225
+ run: async (ctx) => {
226
+ const parent = resolveParentForCreate(ctx);
227
+ const name = await promptName(ctx, {
228
+ title: ctx.labels.newFileTitle,
229
+ message: ctx.labels.newFileMessage,
230
+ placeholder: ctx.labels.newFilePlaceholder,
231
+ defaultValue: ctx.labels.newFileDefault,
232
+ });
233
+ if (name === null) return;
234
+ const err = validateName(ctx, name, { parent });
235
+ if (err) return alertError(ctx, err);
236
+ await ctx.adapter.createFile!(parent, name);
237
+ },
238
+ },
239
+ {
240
+ id: 'new-folder',
241
+ label: (ctx) => ctx.labels.actionNewFolder,
242
+ icon: FolderPlus,
243
+ shortcut: '⌘⇧N',
244
+ available: (ctx) => !!ctx.adapter.createFolder,
245
+ run: async (ctx) => {
246
+ const parent = resolveParentForCreate(ctx);
247
+ const name = await promptName(ctx, {
248
+ title: ctx.labels.newFolderTitle,
249
+ message: ctx.labels.newFolderMessage,
250
+ placeholder: ctx.labels.newFolderPlaceholder,
251
+ defaultValue: ctx.labels.newFolderDefault,
252
+ });
253
+ if (name === null) return;
254
+ const err = validateName(ctx, name, { parent });
255
+ if (err) return alertError(ctx, err);
256
+ await ctx.adapter.createFolder!(parent, name);
257
+ },
258
+ },
259
+ ];
260
+
261
+ /**
262
+ * Default order of items in the auto-built context menu when the
263
+ * consumer doesn't override `defaultMenuItems`. Separators are
264
+ * conventional Finder/Explorer groupings.
265
+ */
266
+ export const DEFAULT_BUILTIN_MENU_ORDER: (TreeBuiltinAction | 'separator')[] = [
267
+ 'rename',
268
+ 'duplicate',
269
+ 'separator',
270
+ 'cut',
271
+ 'copy',
272
+ 'paste',
273
+ 'separator',
274
+ 'new-file',
275
+ 'new-folder',
276
+ 'separator',
277
+ 'delete',
278
+ ];
279
+
280
+ /**
281
+ * Where should a "new file / new folder" land?
282
+ *
283
+ * - target row is a folder → create inside it
284
+ * - target row is a leaf → create as sibling (under its parent)
285
+ * - no target row → root (null)
286
+ */
287
+ function resolveParentForCreate<T>(ctx: BuiltinActionContext<T>): TreeNode<T> | null {
288
+ const { targetNode } = ctx;
289
+ if (!targetNode) return null;
290
+ // We can't know the parent without walking the tree — Tree passes the
291
+ // target's effective "container" via targetNode for folders; leaves go
292
+ // to root for now. Consumers that need sibling-creation can pass a
293
+ // custom contextMenuActions resolver.
294
+ const isFolder = Array.isArray(targetNode.children) || !!targetNode.isFolder;
295
+ return isFolder ? targetNode : null;
296
+ }
297
+
298
+ /**
299
+ * Build the list of `TreeContextMenuItem`s for the current selection /
300
+ * target row using the configured `defaultMenuItems` (or the default
301
+ * order). Returns `null` when no items are available — caller should
302
+ * suppress the menu entirely.
303
+ */
304
+ export function buildDefaultMenuItems<T>(
305
+ ctx: BuiltinActionContext<T>,
306
+ order: (TreeBuiltinAction | 'separator')[] = DEFAULT_BUILTIN_MENU_ORDER,
307
+ ): TreeContextMenuItem<T>[] | null {
308
+ const items: TreeContextMenuItem<T>[] = [];
309
+ let pendingSeparator = false;
310
+
311
+ for (const entry of order) {
312
+ if (entry === 'separator') {
313
+ pendingSeparator = items.length > 0;
314
+ continue;
315
+ }
316
+ const desc = BUILTIN_ACTIONS.find((a) => a.id === entry) as
317
+ | BuiltinActionDescriptor<T>
318
+ | undefined;
319
+ if (!desc) continue;
320
+ if (!desc.available(ctx)) continue;
321
+ if (pendingSeparator) {
322
+ items.push('separator');
323
+ pendingSeparator = false;
324
+ }
325
+ items.push({
326
+ id: desc.id,
327
+ label: desc.label(ctx),
328
+ icon: desc.icon,
329
+ shortcut: desc.shortcut,
330
+ destructive: desc.destructive,
331
+ onSelect: () => void desc.run(ctx),
332
+ });
333
+ }
334
+
335
+ return items.length > 0 ? items : null;
336
+ }
337
+
338
+ /**
339
+ * Internal: run a built-in action by id. Used by Finder hotkeys (P4).
340
+ * Returns `false` if the action isn't currently available (e.g. delete
341
+ * with no selection).
342
+ */
343
+ export async function runBuiltinAction<T>(
344
+ id: TreeBuiltinAction,
345
+ ctx: BuiltinActionContext<T>,
346
+ ): Promise<boolean> {
347
+ const desc = BUILTIN_ACTIONS.find((a) => a.id === id) as
348
+ | BuiltinActionDescriptor<T>
349
+ | undefined;
350
+ if (!desc) return false;
351
+ if (!desc.available(ctx)) return false;
352
+ await desc.run(ctx);
353
+ return true;
354
+ }
355
+
356
+ // Re-export typed bits used by callers.
357
+ export type { TreeRowRenderProps };
@@ -0,0 +1,10 @@
1
+ 'use client';
2
+
3
+ export {
4
+ buildDefaultMenuItems,
5
+ runBuiltinAction,
6
+ DEFAULT_BUILTIN_MENU_ORDER,
7
+ } from './builtin-actions';
8
+ export type { BuiltinActionContext } from './builtin-actions';
9
+ export { useResolvedMenu } from './use-resolved-menu';
10
+ export type { UseResolvedMenuOptions } from './use-resolved-menu';
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import type { ClipboardState } from '../../data/clipboard';
6
+ import type {
7
+ TreeAdapter,
8
+ TreeBuiltinAction,
9
+ TreeContextMenuActionsResolver,
10
+ TreeItemId,
11
+ TreeLabels,
12
+ TreeMovePosition,
13
+ TreeNode,
14
+ } from '../../types';
15
+ import {
16
+ buildDefaultMenuItems,
17
+ type BuiltinActionContext,
18
+ } from './builtin-actions';
19
+
20
+ export interface UseResolvedMenuOptions<T> {
21
+ adapter?: TreeAdapter<T>;
22
+ contextMenuActions?: TreeContextMenuActionsResolver<T>;
23
+ defaultMenuItems?: TreeBuiltinAction[];
24
+ labels: TreeLabels;
25
+ selected: ReadonlySet<TreeItemId>;
26
+ clipboard: ClipboardState;
27
+ nodeById: Map<TreeItemId, TreeNode<T>>;
28
+ getItemName: (node: TreeNode<T>) => string;
29
+ enableInlineRename: boolean;
30
+ startRename: (id: TreeItemId) => void;
31
+ cutToClipboard: (ids: TreeItemId[]) => void;
32
+ copyToClipboard: (ids: TreeItemId[]) => void;
33
+ pasteFromClipboard: (
34
+ target: TreeNode<T> | null,
35
+ position?: TreeMovePosition,
36
+ ) => Promise<void>;
37
+ }
38
+
39
+ /**
40
+ * Build the merged declarative menu resolver — built-in adapter actions
41
+ * (filtered by `defaultMenuItems`) prepended to the consumer's
42
+ * `contextMenuActions` result. Returns `undefined` when neither side
43
+ * supplies anything, so `TreeRow` can skip rendering a menu entirely.
44
+ *
45
+ * The resolver injects `selectedNodes` on every call so the consumer's
46
+ * resolver receives the multi-selection without having to read it from
47
+ * elsewhere. Finder/Explorer convention applies: if the right-clicked
48
+ * row isn't in the current selection, the menu acts on a single-row
49
+ * effective selection (the row itself).
50
+ */
51
+ export function useResolvedMenu<T>(
52
+ opts: UseResolvedMenuOptions<T>,
53
+ ): TreeContextMenuActionsResolver<T> | undefined {
54
+ const {
55
+ adapter,
56
+ contextMenuActions,
57
+ defaultMenuItems,
58
+ labels,
59
+ selected,
60
+ clipboard,
61
+ nodeById,
62
+ getItemName,
63
+ enableInlineRename,
64
+ startRename,
65
+ cutToClipboard,
66
+ copyToClipboard,
67
+ pasteFromClipboard,
68
+ } = opts;
69
+
70
+ return useMemo<TreeContextMenuActionsResolver<T> | undefined>(() => {
71
+ if (!adapter && !contextMenuActions) return undefined;
72
+
73
+ return (rowProps) => {
74
+ const selectedIds = selected.has(rowProps.node.id)
75
+ ? [...selected]
76
+ : [rowProps.node.id];
77
+ const selectedNodes = selectedIds
78
+ .map((id) => nodeById.get(id))
79
+ .filter((n): n is TreeNode<T> => !!n);
80
+
81
+ const builtin = adapter
82
+ ? buildDefaultMenuItems<T>(
83
+ {
84
+ adapter,
85
+ labels,
86
+ selectedNodes,
87
+ targetNode: rowProps.node,
88
+ getName: getItemName,
89
+ startInlineRename:
90
+ enableInlineRename && adapter.rename ? startRename : undefined,
91
+ clipboard: {
92
+ hasItems: !!clipboard && clipboard.ids.length > 0,
93
+ cut: cutToClipboard,
94
+ copy: copyToClipboard,
95
+ paste: () => pasteFromClipboard(rowProps.node, 'inside'),
96
+ },
97
+ } satisfies BuiltinActionContext<T>,
98
+ defaultMenuItems
99
+ ? (defaultMenuItems as (TreeBuiltinAction | 'separator')[])
100
+ : undefined,
101
+ )
102
+ : null;
103
+
104
+ const user =
105
+ contextMenuActions?.({ ...rowProps, selectedNodes }) ?? null;
106
+
107
+ if (!builtin && !user) return null;
108
+ if (!user) return builtin;
109
+ if (!builtin) return user;
110
+ return [...builtin, 'separator', ...user];
111
+ };
112
+ }, [
113
+ adapter,
114
+ contextMenuActions,
115
+ defaultMenuItems,
116
+ labels,
117
+ selected,
118
+ clipboard,
119
+ nodeById,
120
+ getItemName,
121
+ enableInlineRename,
122
+ startRename,
123
+ cutToClipboard,
124
+ copyToClipboard,
125
+ pasteFromClipboard,
126
+ ]);
127
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { usePersistSync } from './use-persist-sync';
4
+ export type { UsePersistSyncOptions } from './use-persist-sync';