@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,74 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ import { saveTreeState } from '../../data/persist';
6
+ import type { TreeItemId } from '../../types';
7
+
8
+ export interface UsePersistSyncOptions {
9
+ expanded: ReadonlySet<TreeItemId>;
10
+ selected: ReadonlySet<TreeItemId>;
11
+ persistKey?: string;
12
+ persistSelection: boolean;
13
+ onSelectionChange?: (ids: TreeItemId[]) => void;
14
+ onExpansionChange?: (ids: TreeItemId[]) => void;
15
+ }
16
+
17
+ /**
18
+ * Persist `expanded` / `selected` into `localStorage` under `persistKey`,
19
+ * and forward changes through the consumer's notify callbacks. We
20
+ * compare by content (not identity) because the reducer recreates
21
+ * `Set`s on every change — without diffing we'd fire `onSelectionChange`
22
+ * on every render.
23
+ */
24
+ export function usePersistSync({
25
+ expanded,
26
+ selected,
27
+ persistKey,
28
+ persistSelection,
29
+ onSelectionChange,
30
+ onExpansionChange,
31
+ }: UsePersistSyncOptions): void {
32
+ // Stable refs so we don't re-bind effects every render.
33
+ const onSelectionChangeRef = useRef(onSelectionChange);
34
+ const onExpansionChangeRef = useRef(onExpansionChange);
35
+ onSelectionChangeRef.current = onSelectionChange;
36
+ onExpansionChangeRef.current = onExpansionChange;
37
+
38
+ const lastSelectedArrRef = useRef<TreeItemId[]>([...selected]);
39
+ const lastExpandedArrRef = useRef<TreeItemId[]>([...expanded]);
40
+
41
+ useEffect(() => {
42
+ const arr = [...expanded];
43
+ if (!setEqualsArr(expanded, lastExpandedArrRef.current)) {
44
+ lastExpandedArrRef.current = arr;
45
+ onExpansionChangeRef.current?.(arr);
46
+ if (persistKey) {
47
+ saveTreeState(persistKey, {
48
+ expandedItems: arr,
49
+ selectedItems: persistSelection ? [...selected] : [],
50
+ });
51
+ }
52
+ }
53
+ }, [expanded, persistKey, persistSelection, selected]);
54
+
55
+ useEffect(() => {
56
+ const arr = [...selected];
57
+ if (!setEqualsArr(selected, lastSelectedArrRef.current)) {
58
+ lastSelectedArrRef.current = arr;
59
+ onSelectionChangeRef.current?.(arr);
60
+ if (persistKey && persistSelection) {
61
+ saveTreeState(persistKey, {
62
+ expandedItems: [...expanded],
63
+ selectedItems: arr,
64
+ });
65
+ }
66
+ }
67
+ }, [selected, persistKey, persistSelection, expanded]);
68
+ }
69
+
70
+ function setEqualsArr(set: ReadonlySet<string>, arr: readonly string[]): boolean {
71
+ if (set.size !== arr.length) return false;
72
+ for (const id of arr) if (!set.has(id)) return false;
73
+ return true;
74
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { useRename } from './use-rename';
4
+ export type { UseRenameOptions, UseRenameReturn } from './use-rename';
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import { useCallback } from 'react';
4
+
5
+ import { getDialog } from '@djangocfg/ui-core/lib/dialog-service';
6
+
7
+ import type {
8
+ TreeAdapter,
9
+ TreeItemId,
10
+ TreeLabels,
11
+ TreeNode,
12
+ } from '../../types';
13
+ import type { Action } from '../state';
14
+
15
+ export interface UseRenameOptions<T> {
16
+ dispatch: React.Dispatch<Action>;
17
+ adapter?: TreeAdapter<T>;
18
+ /** Master switch — hooks honour it on every entrypoint. */
19
+ enableInlineRename: boolean;
20
+ nodeById: Map<TreeItemId, TreeNode<T>>;
21
+ getItemName: (node: TreeNode<T>) => string;
22
+ labels: TreeLabels;
23
+ }
24
+
25
+ export interface UseRenameReturn {
26
+ /** True only when both the host opted in AND adapter exposes `rename`. */
27
+ enabled: boolean;
28
+ startRename: (id: TreeItemId) => void;
29
+ cancelRename: () => void;
30
+ commitRename: (id: TreeItemId, nextName: string) => Promise<boolean>;
31
+ }
32
+
33
+ /**
34
+ * Inline rename callbacks. The visible UI lives in `TreeRenameInput`;
35
+ * this hook owns the start/cancel/commit transitions and dialog-driven
36
+ * error surfacing.
37
+ */
38
+ export function useRename<T>({
39
+ dispatch,
40
+ adapter,
41
+ enableInlineRename,
42
+ nodeById,
43
+ getItemName,
44
+ labels,
45
+ }: UseRenameOptions<T>): UseRenameReturn {
46
+ const enabled = enableInlineRename && !!adapter?.rename;
47
+
48
+ const startRename = useCallback(
49
+ (id: TreeItemId) => {
50
+ if (!enableInlineRename) return;
51
+ if (!adapter?.rename) {
52
+ if (process.env.NODE_ENV !== 'production') {
53
+ // eslint-disable-next-line no-console
54
+ console.warn(
55
+ '[Tree] startRename called but adapter.rename is not defined.',
56
+ );
57
+ }
58
+ return;
59
+ }
60
+ dispatch({ type: 'start-rename', id });
61
+ },
62
+ [dispatch, enableInlineRename, adapter],
63
+ );
64
+
65
+ const cancelRename = useCallback(
66
+ () => dispatch({ type: 'stop-rename' }),
67
+ [dispatch],
68
+ );
69
+
70
+ const commitRename = useCallback(
71
+ async (id: TreeItemId, nextName: string): Promise<boolean> => {
72
+ if (!adapter?.rename) {
73
+ dispatch({ type: 'stop-rename' });
74
+ return false;
75
+ }
76
+ const node = nodeById.get(id);
77
+ if (!node) {
78
+ dispatch({ type: 'stop-rename' });
79
+ return false;
80
+ }
81
+ const trimmed = nextName.trim();
82
+ if (trimmed === getItemName(node)) {
83
+ dispatch({ type: 'stop-rename' });
84
+ return true;
85
+ }
86
+ let err: string | null = null;
87
+ if (trimmed === '') err = labels.invalidNameEmpty;
88
+ else err = adapter.validateName?.(trimmed, { node }) ?? null;
89
+
90
+ if (err) {
91
+ const dialog = getDialog();
92
+ await dialog?.alert({ title: labels.error, message: err });
93
+ return false;
94
+ }
95
+
96
+ try {
97
+ await adapter.rename(node, trimmed);
98
+ } catch (e) {
99
+ const dialog = getDialog();
100
+ await dialog?.alert({
101
+ title: labels.error,
102
+ message: e instanceof Error ? e.message : String(e),
103
+ });
104
+ return false;
105
+ }
106
+ dispatch({ type: 'stop-rename' });
107
+ return true;
108
+ },
109
+ [dispatch, adapter, nodeById, getItemName, labels],
110
+ );
111
+
112
+ return { enabled, startRename, cancelRename, commitRename };
113
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { useSelection } from './use-selection';
4
+ export type { UseSelectionOptions, UseSelectionReturn } from './use-selection';
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+
5
+ import {
6
+ selectionFromClick,
7
+ selectionFromMove,
8
+ selectionSelectAll,
9
+ type ClickModifiers,
10
+ } from '../../data/selection';
11
+ import type {
12
+ FlatRow,
13
+ TreeItemId,
14
+ TreeSelectionMode,
15
+ } from '../../types';
16
+ import type { Action } from '../state';
17
+
18
+ export interface UseSelectionOptions<T> {
19
+ dispatch: React.Dispatch<Action>;
20
+ selectionMode: TreeSelectionMode;
21
+ /** Current visible rows — fed in each render, latched into a ref. */
22
+ flatRows: FlatRow<T>[];
23
+ /** Current selection snapshot (latched into a ref alongside `flatRows`). */
24
+ selected: ReadonlySet<TreeItemId>;
25
+ anchor: TreeItemId | null;
26
+ focused: TreeItemId | null;
27
+ }
28
+
29
+ export interface UseSelectionReturn {
30
+ select: (id: TreeItemId) => void;
31
+ setSelectedIds: (ids: TreeItemId[]) => void;
32
+ clearSelection: () => void;
33
+ clickSelect: (id: TreeItemId, mods: ClickModifiers) => void;
34
+ moveSelect: (id: TreeItemId, opts: { extend: boolean }) => void;
35
+ selectAll: () => void;
36
+ setFocus: (id: TreeItemId | null) => void;
37
+ }
38
+
39
+ /**
40
+ * Finder/Explorer-style selection actions. Reads the *current* rows /
41
+ * selection from refs latched per-render, so the returned callbacks
42
+ * stay stable across renders (which matters for `react-hotkeys-hook`
43
+ * and for memoised TreeRow children).
44
+ */
45
+ export function useSelection<T>({
46
+ dispatch,
47
+ selectionMode,
48
+ flatRows,
49
+ selected,
50
+ anchor,
51
+ focused,
52
+ }: UseSelectionOptions<T>): UseSelectionReturn {
53
+ // Latch the snapshot the callbacks read from.
54
+ const flatRowsRef = useRef<FlatRow<T>[]>(flatRows);
55
+ flatRowsRef.current = flatRows;
56
+ const selectionRef = useRef({ selected, anchor, focused });
57
+ selectionRef.current = { selected, anchor, focused };
58
+
59
+ const select = useCallback(
60
+ (id: TreeItemId) => dispatch({ type: 'select', id, mode: selectionMode }),
61
+ [dispatch, selectionMode],
62
+ );
63
+ const setSelectedIds = useCallback(
64
+ (ids: TreeItemId[]) => dispatch({ type: 'select-many', ids }),
65
+ [dispatch],
66
+ );
67
+ const clearSelection = useCallback(
68
+ () => dispatch({ type: 'clear-selection' }),
69
+ [dispatch],
70
+ );
71
+ const setFocus = useCallback(
72
+ (id: TreeItemId | null) => dispatch({ type: 'focus', id }),
73
+ [dispatch],
74
+ );
75
+
76
+ const clickSelect = useCallback(
77
+ (id: TreeItemId, mods: ClickModifiers) => {
78
+ if (selectionMode === 'none') return;
79
+ if (selectionMode === 'single') {
80
+ dispatch({ type: 'select', id, mode: 'single' });
81
+ return;
82
+ }
83
+ const next = selectionFromClick(
84
+ selectionRef.current,
85
+ flatRowsRef.current,
86
+ id,
87
+ mods,
88
+ true,
89
+ );
90
+ dispatch({
91
+ type: 'selection-replace',
92
+ selected: [...next.selected],
93
+ anchor: next.anchor,
94
+ focused: next.focused,
95
+ });
96
+ },
97
+ [dispatch, selectionMode],
98
+ );
99
+
100
+ const moveSelect = useCallback(
101
+ (id: TreeItemId, opts: { extend: boolean }) => {
102
+ if (selectionMode !== 'multiple' || !opts.extend) {
103
+ dispatch({ type: 'focus', id });
104
+ return;
105
+ }
106
+ const next = selectionFromMove(
107
+ selectionRef.current,
108
+ flatRowsRef.current,
109
+ id,
110
+ true,
111
+ true,
112
+ );
113
+ dispatch({
114
+ type: 'selection-replace',
115
+ selected: [...next.selected],
116
+ anchor: next.anchor,
117
+ focused: next.focused,
118
+ });
119
+ },
120
+ [dispatch, selectionMode],
121
+ );
122
+
123
+ const selectAll = useCallback(() => {
124
+ if (selectionMode !== 'multiple') return;
125
+ const next = selectionSelectAll(
126
+ flatRowsRef.current,
127
+ selectionRef.current.focused,
128
+ );
129
+ dispatch({
130
+ type: 'selection-replace',
131
+ selected: [...next.selected],
132
+ anchor: next.anchor,
133
+ focused: next.focused,
134
+ });
135
+ }, [dispatch, selectionMode]);
136
+
137
+ return {
138
+ select,
139
+ setSelectedIds,
140
+ clearSelection,
141
+ clickSelect,
142
+ moveSelect,
143
+ selectAll,
144
+ setFocus,
145
+ };
146
+ }
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+
3
+ export type { State, Action } from './types';
4
+ export { reducer } from './reducer';
5
+ export { createInitialState } from './initial';
6
+ export type { CreateInitialStateInput } from './initial';
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import type { PersistedTreeState } from '../../data/persist';
4
+ import type { TreeItemId } from '../../types';
5
+ import type { State } from './types';
6
+
7
+ export interface CreateInitialStateInput {
8
+ persisted: PersistedTreeState | null;
9
+ initialExpandedIds?: TreeItemId[];
10
+ initialSelectedIds?: TreeItemId[];
11
+ persistSelection: boolean;
12
+ }
13
+
14
+ /**
15
+ * Build the reducer's initial state from persistence + initial props.
16
+ * Anchor is set to the first restored selection (if any) so a hard
17
+ * reload doesn't break shift-extend.
18
+ */
19
+ export function createInitialState(input: CreateInitialStateInput): State {
20
+ const { persisted, initialExpandedIds, initialSelectedIds, persistSelection } =
21
+ input;
22
+ const initialSelected = new Set(
23
+ (persistSelection ? persisted?.selectedItems : undefined) ??
24
+ initialSelectedIds ??
25
+ [],
26
+ );
27
+ const initialAnchor: TreeItemId | null =
28
+ initialSelected.size > 0
29
+ ? (initialSelected.values().next().value as TreeItemId)
30
+ : null;
31
+ return {
32
+ expanded: new Set(persisted?.expandedItems ?? initialExpandedIds ?? []),
33
+ selected: initialSelected,
34
+ anchor: initialAnchor,
35
+ focused: null,
36
+ query: '',
37
+ renaming: null,
38
+ clipboard: null,
39
+ cacheTick: 0,
40
+ };
41
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import type { Action, State } from './types';
4
+
5
+ /**
6
+ * Pure reducer for `<TreeProvider>`. Intentionally small — most domain
7
+ * logic (Finder selection rules, async cache, persistence) lives in
8
+ * dedicated hooks under `context/<feature>/` and only dispatches one of
9
+ * these actions when the result needs to land in React state.
10
+ */
11
+ export function reducer(state: State, action: Action): State {
12
+ switch (action.type) {
13
+ case 'expand': {
14
+ if (state.expanded.has(action.id)) return state;
15
+ const next = new Set(state.expanded);
16
+ next.add(action.id);
17
+ return { ...state, expanded: next };
18
+ }
19
+ case 'collapse': {
20
+ if (!state.expanded.has(action.id)) return state;
21
+ const next = new Set(state.expanded);
22
+ next.delete(action.id);
23
+ return { ...state, expanded: next };
24
+ }
25
+ case 'toggle': {
26
+ const next = new Set(state.expanded);
27
+ if (next.has(action.id)) next.delete(action.id);
28
+ else next.add(action.id);
29
+ return { ...state, expanded: next };
30
+ }
31
+ case 'set-expanded':
32
+ return { ...state, expanded: new Set(action.ids) };
33
+ case 'select': {
34
+ if (action.mode === 'none') return state;
35
+ if (action.mode === 'single') {
36
+ return {
37
+ ...state,
38
+ selected: new Set([action.id]),
39
+ anchor: action.id,
40
+ focused: action.id,
41
+ };
42
+ }
43
+ const next = new Set(state.selected);
44
+ if (next.has(action.id)) next.delete(action.id);
45
+ else next.add(action.id);
46
+ return { ...state, selected: next, anchor: action.id, focused: action.id };
47
+ }
48
+ case 'select-many':
49
+ return { ...state, selected: new Set(action.ids) };
50
+ case 'clear-selection':
51
+ return { ...state, selected: new Set(), anchor: null };
52
+ case 'selection-replace':
53
+ return {
54
+ ...state,
55
+ selected: new Set(action.selected),
56
+ anchor: action.anchor,
57
+ focused: action.focused,
58
+ };
59
+ case 'set-anchor':
60
+ return { ...state, anchor: action.id };
61
+ case 'focus':
62
+ return { ...state, focused: action.id };
63
+ case 'set-query':
64
+ return { ...state, query: action.q };
65
+ case 'start-rename':
66
+ return { ...state, renaming: action.id };
67
+ case 'stop-rename':
68
+ return state.renaming === null ? state : { ...state, renaming: null };
69
+ case 'clipboard-set':
70
+ return { ...state, clipboard: action.payload };
71
+ case 'cache-tick':
72
+ return { ...state, cacheTick: state.cacheTick + 1 };
73
+ default:
74
+ return state;
75
+ }
76
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import type { ClipboardState } from '../../data/clipboard';
4
+ import type { TreeItemId, TreeSelectionMode } from '../../types';
5
+
6
+ /**
7
+ * The full reducer state for `<TreeProvider>`. Async children live
8
+ * outside this object (see `async-children/` hook) since they're
9
+ * managed via refs to avoid React re-renders on every fetch.
10
+ */
11
+ export interface State {
12
+ expanded: Set<TreeItemId>;
13
+ selected: Set<TreeItemId>;
14
+ /** Anchor for shift-range selection (Finder/Explorer). */
15
+ anchor: TreeItemId | null;
16
+ focused: TreeItemId | null;
17
+ query: string;
18
+ /** Id of the row currently in inline-rename mode (P3). */
19
+ renaming: TreeItemId | null;
20
+ /** Tree-local clipboard (cut / copy) — P5. */
21
+ clipboard: ClipboardState;
22
+ /** Bumped on every cache mutation so memos see a fresh dep. */
23
+ cacheTick: number;
24
+ }
25
+
26
+ export type Action =
27
+ | { type: 'expand'; id: TreeItemId }
28
+ | { type: 'collapse'; id: TreeItemId }
29
+ | { type: 'toggle'; id: TreeItemId }
30
+ | { type: 'set-expanded'; ids: TreeItemId[] }
31
+ | { type: 'select'; id: TreeItemId; mode: TreeSelectionMode }
32
+ | { type: 'select-many'; ids: TreeItemId[] }
33
+ | { type: 'clear-selection' }
34
+ | {
35
+ type: 'selection-replace';
36
+ selected: TreeItemId[];
37
+ anchor: TreeItemId | null;
38
+ focused: TreeItemId | null;
39
+ }
40
+ | { type: 'set-anchor'; id: TreeItemId | null }
41
+ | { type: 'focus'; id: TreeItemId | null }
42
+ | { type: 'set-query'; q: string }
43
+ | { type: 'start-rename'; id: TreeItemId }
44
+ | { type: 'stop-rename' }
45
+ | { type: 'clipboard-set'; payload: ClipboardState }
46
+ | { type: 'cache-tick' };
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import type { TreeItemId } from '../types';
4
+
5
+ // =====================================================================
6
+ // Tree-local clipboard.
7
+ //
8
+ // Why not the system clipboard? Because we need a visual "cut" state
9
+ // (Finder/Explorer dim cut items) and a single source of truth across
10
+ // Tree's own keyboard / menu / DnD entry points. The system clipboard
11
+ // can't carry that semantics in a way that survives focus changes.
12
+ //
13
+ // Clipboard owns *ids*, not full nodes — Tree resolves them through
14
+ // `getNodeById` at paste time. This keeps the model immutable to the
15
+ // underlying data refreshing under us (async load, refresh, etc.).
16
+ // =====================================================================
17
+
18
+ export type ClipboardKind = 'cut' | 'copy';
19
+
20
+ export interface ClipboardEntry {
21
+ kind: ClipboardKind;
22
+ ids: TreeItemId[];
23
+ }
24
+
25
+ export type ClipboardState = ClipboardEntry | null;
26
+
27
+ /**
28
+ * Was this id put on the clipboard with `kind: 'cut'`? Used by `TreeRow`
29
+ * to render dimmed (`opacity-60`) rows the way Finder does.
30
+ */
31
+ export function isCutId(state: ClipboardState, id: TreeItemId): boolean {
32
+ return state?.kind === 'cut' && state.ids.includes(id);
33
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import type { FlatRow, TreeItemId, TreeMovePosition, TreeNode } from '../types';
4
+
5
+ // =====================================================================
6
+ // Drag-and-drop primitives — pure, framework-agnostic.
7
+ //
8
+ // @dnd-kit handles the pointer/keyboard sensors and overlay rendering;
9
+ // these helpers only translate pointer geometry into a domain-level
10
+ // drop position (`before` / `inside` / `after`) and validate whether
11
+ // a given drag/drop combo is allowed.
12
+ //
13
+ // Folder vs leaf semantics:
14
+ // - leaves → no `inside` zone; row splits into top/bottom halves
15
+ // for `before` / `after` reorder
16
+ // - folders → top third = `before`, middle third = `inside`,
17
+ // bottom third = `after`
18
+ // =====================================================================
19
+
20
+ export interface DropZoneInput {
21
+ /** Pointer Y in viewport coordinates. */
22
+ pointerY: number;
23
+ /** Row bounding box (`getBoundingClientRect()`). */
24
+ rowRect: { top: number; bottom: number; height: number };
25
+ /** Folders accept `inside` drops; leaves only reorder via before/after. */
26
+ isFolder: boolean;
27
+ }
28
+
29
+ /**
30
+ * Translate pointer geometry into a drop position relative to the row.
31
+ *
32
+ * For folders the row is split into three zones (top third / middle /
33
+ * bottom third). For leaves it's split in half (before / after).
34
+ */
35
+ export function resolveDropZone(input: DropZoneInput): TreeMovePosition {
36
+ const { pointerY, rowRect, isFolder } = input;
37
+ const offset = pointerY - rowRect.top;
38
+ const ratio = rowRect.height > 0 ? offset / rowRect.height : 0.5;
39
+
40
+ if (isFolder) {
41
+ if (ratio < 0.33) return 'before';
42
+ if (ratio > 0.66) return 'after';
43
+ return 'inside';
44
+ }
45
+ return ratio < 0.5 ? 'before' : 'after';
46
+ }
47
+
48
+ // =====================================================================
49
+ // canDrop — default validator.
50
+ //
51
+ // Rejects:
52
+ // 1. Dropping a node onto itself.
53
+ // 2. Dropping a folder into one of its own descendants (would create
54
+ // a cycle).
55
+ // 3. Dropping `inside` a non-folder (defensive — UI already routes
56
+ // these as `before`/`after`, but the API can be called directly).
57
+ //
58
+ // Consumers can pass their own `canDrop` via `<TreeRoot canDrop={…}>`
59
+ // to layer extra rules on top (e.g. read-only branches, type matching).
60
+ // =====================================================================
61
+
62
+ export interface CanDropInput<T> {
63
+ /** Nodes being dragged. */
64
+ source: TreeNode<T>[];
65
+ /** Row under the pointer (`null` = root drop zone). */
66
+ target: TreeNode<T> | null;
67
+ /** Resolved drop position. */
68
+ position: TreeMovePosition;
69
+ /** Tree's id→node lookup, used to walk descendants. */
70
+ getNodeById: (id: TreeItemId) => TreeNode<T> | undefined;
71
+ }
72
+
73
+ export function defaultCanDrop<T>(input: CanDropInput<T>): boolean {
74
+ const { source, target, position } = input;
75
+ if (source.length === 0) return false;
76
+ if (!target) return true; // root drop is always allowed when no other rule rejects.
77
+
78
+ // Inside a leaf doesn't make sense.
79
+ if (position === 'inside') {
80
+ const isFolder = Array.isArray(target.children) || !!target.isFolder;
81
+ if (!isFolder) return false;
82
+ }
83
+
84
+ for (const node of source) {
85
+ if (node.id === target.id) return false;
86
+ if (isDescendant(node, target.id)) return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Walk `root`'s inline children searching for `id`. Cached async children
93
+ * are *not* walked here because the cycle check needs to be cheap and
94
+ * deterministic — a missing cycle in unloaded branches is acceptable
95
+ * (the consumer's `adapter.move` is the final gatekeeper).
96
+ */
97
+ function isDescendant<T>(root: TreeNode<T>, id: TreeItemId): boolean {
98
+ if (!Array.isArray(root.children)) return false;
99
+ for (const child of root.children) {
100
+ if (child.id === id) return true;
101
+ if (isDescendant(child, id)) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ // =====================================================================
107
+ // DataTransfer mime — used both for in-tree drags and (later) for
108
+ // receiving dragged files from the OS. The id payload is the JSON-
109
+ // encoded list of `TreeItemId`s.
110
+ // =====================================================================
111
+
112
+ export const TREE_DND_MIME = 'application/x-djangocfg-tree';
113
+
114
+ /**
115
+ * Sentinel droppable id used by `<TreeEmptyArea>` to claim the
116
+ * "root drop target" slot. `<TreeDndProvider>` recognises this id and
117
+ * maps it to `{ id: null, position: 'inside' }` in `dnd.dropTarget`.
118
+ *
119
+ * Lives in `data/` (not in the component) so both producer and
120
+ * consumer reference one constant instead of duplicating the magic
121
+ * string.
122
+ */
123
+ export const TREE_ROOT_DROP_ID = '__tree_root_drop__';