@djangocfg/ui-tools 2.1.413 → 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.
- package/dist/file-icon/index.d.cts +1 -1
- package/dist/file-icon/index.d.ts +1 -1
- package/dist/slots-ClRpIzoh.d.cts +88 -0
- package/dist/slots-ClRpIzoh.d.ts +88 -0
- package/dist/tree/index.cjs +1994 -276
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +717 -72
- package/dist/tree/index.d.ts +717 -72
- package/dist/tree/index.mjs +1984 -279
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +10 -6
- package/src/tools/chat/README.md +111 -1
- package/src/tools/chat/composer/Composer.tsx +138 -17
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
- package/src/tools/chat/composer/index.ts +22 -0
- package/src/tools/chat/composer/slash/README.md +187 -0
- package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
- package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
- package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
- package/src/tools/chat/composer/slash/index.ts +44 -0
- package/src/tools/chat/composer/slash/labels.ts +19 -0
- package/src/tools/chat/composer/slash/state.ts +168 -0
- package/src/tools/chat/composer/slash/types.ts +64 -0
- package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
- package/src/tools/chat/composer/types.ts +8 -0
- package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
- package/src/tools/chat/shell/index.ts +6 -0
- package/src/tools/data/Listbox/lazy.tsx +1 -1
- package/src/tools/data/Masonry/lazy.tsx +1 -1
- package/src/tools/data/Timeline/lazy.tsx +1 -1
- package/src/tools/data/Tree/FinderTree.tsx +42 -0
- package/src/tools/data/Tree/README.md +337 -208
- package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
- package/src/tools/data/Tree/TreeRoot.tsx +170 -55
- package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
- package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
- package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
- package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
- package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +92 -8
- package/src/tools/data/Tree/components/index.ts +6 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +204 -363
- package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
- package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/tools/data/Tree/context/async-children/index.ts +8 -0
- package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
- package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/tools/data/Tree/context/dnd/index.ts +8 -0
- package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/tools/data/Tree/context/expansion/index.ts +4 -0
- package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/tools/data/Tree/context/hooks.ts +68 -1
- package/src/tools/data/Tree/context/index.ts +3 -0
- package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
- package/src/tools/data/Tree/context/menu/index.ts +10 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +127 -0
- package/src/tools/data/Tree/context/persist/index.ts +4 -0
- package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/tools/data/Tree/context/rename/index.ts +4 -0
- package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
- package/src/tools/data/Tree/context/selection/index.ts +4 -0
- package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
- package/src/tools/data/Tree/context/state/index.ts +6 -0
- package/src/tools/data/Tree/context/state/initial.ts +41 -0
- package/src/tools/data/Tree/context/state/reducer.ts +76 -0
- package/src/tools/data/Tree/context/state/types.ts +46 -0
- package/src/tools/data/Tree/data/clipboard.ts +33 -0
- package/src/tools/data/Tree/data/dnd.ts +123 -0
- package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
- package/src/tools/data/Tree/data/index.ts +19 -0
- package/src/tools/data/Tree/data/renameUtils.ts +51 -0
- package/src/tools/data/Tree/data/selection.ts +157 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/tools/data/Tree/hooks/index.ts +23 -4
- package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
- package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
- package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
- package/src/tools/data/Tree/index.tsx +25 -2
- package/src/tools/data/Tree/types/activation.ts +30 -0
- package/src/tools/data/Tree/types/adapter.ts +70 -0
- package/src/tools/data/Tree/types/index.ts +27 -0
- package/src/tools/data/Tree/types/labels.ts +97 -0
- package/src/tools/data/Tree/types/loader.ts +9 -0
- package/src/tools/data/Tree/types/node.ts +38 -0
- package/src/tools/data/Tree/types/root-props.ts +142 -0
- package/src/tools/data/Tree/types/selection.ts +3 -0
- package/src/tools/data/Tree/types/slots.ts +64 -0
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +1 -1
- package/src/tools/dev/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +1 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
- package/src/tools/forms/MarkdownEditor/index.ts +1 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
- package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
- package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
- package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
- package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
- package/src/tools/forms/MarkdownEditor/styles.css +18 -0
- package/src/tools/index.ts +2 -2
- package/dist/types-j2vhn4Kv.d.cts +0 -241
- package/dist/types-j2vhn4Kv.d.ts +0 -241
- package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
- 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
|
-
[
|
|
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(
|
|
@@ -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
|
+
}
|