@molecule/app-ide-react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/LICENSE +115 -0
  2. package/dist/chatTimelineOrdering.d.ts +50 -0
  3. package/dist/chatTimelineOrdering.d.ts.map +1 -0
  4. package/dist/chatTimelineOrdering.js +46 -0
  5. package/dist/chatTimelineOrdering.js.map +1 -0
  6. package/dist/command-metadata.d.ts +78 -0
  7. package/dist/command-metadata.d.ts.map +1 -0
  8. package/dist/command-metadata.js +214 -0
  9. package/dist/command-metadata.js.map +1 -0
  10. package/dist/components/ActivityCard.d.ts +28 -0
  11. package/dist/components/ActivityCard.d.ts.map +1 -0
  12. package/dist/components/ActivityCard.js +62 -0
  13. package/dist/components/ActivityCard.js.map +1 -0
  14. package/dist/components/AutoCommitBadge.d.ts +58 -0
  15. package/dist/components/AutoCommitBadge.d.ts.map +1 -0
  16. package/dist/components/AutoCommitBadge.js +78 -0
  17. package/dist/components/AutoCommitBadge.js.map +1 -0
  18. package/dist/components/ChatPanel.d.ts +184 -0
  19. package/dist/components/ChatPanel.d.ts.map +1 -0
  20. package/dist/components/ChatPanel.js +6691 -0
  21. package/dist/components/ChatPanel.js.map +1 -0
  22. package/dist/components/CommandPalette.d.ts +18 -0
  23. package/dist/components/CommandPalette.d.ts.map +1 -0
  24. package/dist/components/CommandPalette.js +33 -0
  25. package/dist/components/CommandPalette.js.map +1 -0
  26. package/dist/components/DeviceFrameSelector.d.ts +60 -0
  27. package/dist/components/DeviceFrameSelector.d.ts.map +1 -0
  28. package/dist/components/DeviceFrameSelector.js +230 -0
  29. package/dist/components/DeviceFrameSelector.js.map +1 -0
  30. package/dist/components/EditorPanel.d.ts +17 -0
  31. package/dist/components/EditorPanel.d.ts.map +1 -0
  32. package/dist/components/EditorPanel.js +241 -0
  33. package/dist/components/EditorPanel.js.map +1 -0
  34. package/dist/components/FileExplorer.d.ts +64 -0
  35. package/dist/components/FileExplorer.d.ts.map +1 -0
  36. package/dist/components/FileExplorer.js +1111 -0
  37. package/dist/components/FileExplorer.js.map +1 -0
  38. package/dist/components/FileExplorerContextMenu.d.ts +37 -0
  39. package/dist/components/FileExplorerContextMenu.d.ts.map +1 -0
  40. package/dist/components/FileExplorerContextMenu.js +241 -0
  41. package/dist/components/FileExplorerContextMenu.js.map +1 -0
  42. package/dist/components/HelpCard.d.ts +34 -0
  43. package/dist/components/HelpCard.d.ts.map +1 -0
  44. package/dist/components/HelpCard.js +28 -0
  45. package/dist/components/HelpCard.js.map +1 -0
  46. package/dist/components/Icon.d.ts +50 -0
  47. package/dist/components/Icon.d.ts.map +1 -0
  48. package/dist/components/Icon.js +25 -0
  49. package/dist/components/Icon.js.map +1 -0
  50. package/dist/components/KeyboardShortcutsPanel.d.ts +19 -0
  51. package/dist/components/KeyboardShortcutsPanel.d.ts.map +1 -0
  52. package/dist/components/KeyboardShortcutsPanel.js +187 -0
  53. package/dist/components/KeyboardShortcutsPanel.js.map +1 -0
  54. package/dist/components/MarkdownContent.d.ts +52 -0
  55. package/dist/components/MarkdownContent.d.ts.map +1 -0
  56. package/dist/components/MarkdownContent.js +303 -0
  57. package/dist/components/MarkdownContent.js.map +1 -0
  58. package/dist/components/ModelsTable.d.ts +38 -0
  59. package/dist/components/ModelsTable.d.ts.map +1 -0
  60. package/dist/components/ModelsTable.js +96 -0
  61. package/dist/components/ModelsTable.js.map +1 -0
  62. package/dist/components/PreviewPanel.d.ts +66 -0
  63. package/dist/components/PreviewPanel.d.ts.map +1 -0
  64. package/dist/components/PreviewPanel.js +1752 -0
  65. package/dist/components/PreviewPanel.js.map +1 -0
  66. package/dist/components/QuickOpen.d.ts +20 -0
  67. package/dist/components/QuickOpen.d.ts.map +1 -0
  68. package/dist/components/QuickOpen.js +72 -0
  69. package/dist/components/QuickOpen.js.map +1 -0
  70. package/dist/components/QuickPicker.d.ts +21 -0
  71. package/dist/components/QuickPicker.d.ts.map +1 -0
  72. package/dist/components/QuickPicker.js +174 -0
  73. package/dist/components/QuickPicker.js.map +1 -0
  74. package/dist/components/RelevantSkillSuggestion.d.ts +41 -0
  75. package/dist/components/RelevantSkillSuggestion.d.ts.map +1 -0
  76. package/dist/components/RelevantSkillSuggestion.js +85 -0
  77. package/dist/components/RelevantSkillSuggestion.js.map +1 -0
  78. package/dist/components/ReportModal.d.ts +35 -0
  79. package/dist/components/ReportModal.d.ts.map +1 -0
  80. package/dist/components/ReportModal.js +112 -0
  81. package/dist/components/ReportModal.js.map +1 -0
  82. package/dist/components/ResizeHandle.d.ts +21 -0
  83. package/dist/components/ResizeHandle.d.ts.map +1 -0
  84. package/dist/components/ResizeHandle.js +109 -0
  85. package/dist/components/ResizeHandle.js.map +1 -0
  86. package/dist/components/ScriptsCard.d.ts +37 -0
  87. package/dist/components/ScriptsCard.d.ts.map +1 -0
  88. package/dist/components/ScriptsCard.js +177 -0
  89. package/dist/components/ScriptsCard.js.map +1 -0
  90. package/dist/components/SearchPanel.d.ts +18 -0
  91. package/dist/components/SearchPanel.d.ts.map +1 -0
  92. package/dist/components/SearchPanel.js +449 -0
  93. package/dist/components/SearchPanel.js.map +1 -0
  94. package/dist/components/SettingsCard.d.ts +49 -0
  95. package/dist/components/SettingsCard.d.ts.map +1 -0
  96. package/dist/components/SettingsCard.js +59 -0
  97. package/dist/components/SettingsCard.js.map +1 -0
  98. package/dist/components/ShareModal.d.ts +32 -0
  99. package/dist/components/ShareModal.d.ts.map +1 -0
  100. package/dist/components/ShareModal.js +126 -0
  101. package/dist/components/ShareModal.js.map +1 -0
  102. package/dist/components/SidebarTabs.d.ts +18 -0
  103. package/dist/components/SidebarTabs.d.ts.map +1 -0
  104. package/dist/components/SidebarTabs.js +20 -0
  105. package/dist/components/SidebarTabs.js.map +1 -0
  106. package/dist/components/SkillsCard.d.ts +86 -0
  107. package/dist/components/SkillsCard.d.ts.map +1 -0
  108. package/dist/components/SkillsCard.js +227 -0
  109. package/dist/components/SkillsCard.js.map +1 -0
  110. package/dist/components/StreamingIndicator.d.ts +50 -0
  111. package/dist/components/StreamingIndicator.d.ts.map +1 -0
  112. package/dist/components/StreamingIndicator.js +142 -0
  113. package/dist/components/StreamingIndicator.js.map +1 -0
  114. package/dist/components/TabBar.d.ts +37 -0
  115. package/dist/components/TabBar.d.ts.map +1 -0
  116. package/dist/components/TabBar.js +187 -0
  117. package/dist/components/TabBar.js.map +1 -0
  118. package/dist/components/TipCard.d.ts +22 -0
  119. package/dist/components/TipCard.d.ts.map +1 -0
  120. package/dist/components/TipCard.js +73 -0
  121. package/dist/components/TipCard.js.map +1 -0
  122. package/dist/components/ToolCallCard.d.ts +18 -0
  123. package/dist/components/ToolCallCard.d.ts.map +1 -0
  124. package/dist/components/ToolCallCard.js +716 -0
  125. package/dist/components/ToolCallCard.js.map +1 -0
  126. package/dist/components/UserAvatar.d.ts +45 -0
  127. package/dist/components/UserAvatar.d.ts.map +1 -0
  128. package/dist/components/UserAvatar.js +80 -0
  129. package/dist/components/UserAvatar.js.map +1 -0
  130. package/dist/components/WorkspaceLayout.d.ts +19 -0
  131. package/dist/components/WorkspaceLayout.d.ts.map +1 -0
  132. package/dist/components/WorkspaceLayout.js +56 -0
  133. package/dist/components/WorkspaceLayout.js.map +1 -0
  134. package/dist/components/activity-utilities.d.ts +107 -0
  135. package/dist/components/activity-utilities.d.ts.map +1 -0
  136. package/dist/components/activity-utilities.js +154 -0
  137. package/dist/components/activity-utilities.js.map +1 -0
  138. package/dist/components/chat-autocommit-utilities.d.ts +172 -0
  139. package/dist/components/chat-autocommit-utilities.d.ts.map +1 -0
  140. package/dist/components/chat-autocommit-utilities.js +185 -0
  141. package/dist/components/chat-autocommit-utilities.js.map +1 -0
  142. package/dist/components/chat-card-style.d.ts +64 -0
  143. package/dist/components/chat-card-style.d.ts.map +1 -0
  144. package/dist/components/chat-card-style.js +74 -0
  145. package/dist/components/chat-card-style.js.map +1 -0
  146. package/dist/components/chat-commands.d.ts +17 -0
  147. package/dist/components/chat-commands.d.ts.map +1 -0
  148. package/dist/components/chat-commands.js +17 -0
  149. package/dist/components/chat-commands.js.map +1 -0
  150. package/dist/components/chat-commit-utilities.d.ts +29 -0
  151. package/dist/components/chat-commit-utilities.d.ts.map +1 -0
  152. package/dist/components/chat-commit-utilities.js +31 -0
  153. package/dist/components/chat-commit-utilities.js.map +1 -0
  154. package/dist/components/chat-cost-utilities.d.ts +62 -0
  155. package/dist/components/chat-cost-utilities.d.ts.map +1 -0
  156. package/dist/components/chat-cost-utilities.js +66 -0
  157. package/dist/components/chat-cost-utilities.js.map +1 -0
  158. package/dist/components/chat-effort-utilities.d.ts +96 -0
  159. package/dist/components/chat-effort-utilities.d.ts.map +1 -0
  160. package/dist/components/chat-effort-utilities.js +97 -0
  161. package/dist/components/chat-effort-utilities.js.map +1 -0
  162. package/dist/components/chat-help-utilities.d.ts +89 -0
  163. package/dist/components/chat-help-utilities.d.ts.map +1 -0
  164. package/dist/components/chat-help-utilities.js +131 -0
  165. package/dist/components/chat-help-utilities.js.map +1 -0
  166. package/dist/components/chat-model-mode-utilities.d.ts +143 -0
  167. package/dist/components/chat-model-mode-utilities.d.ts.map +1 -0
  168. package/dist/components/chat-model-mode-utilities.js +191 -0
  169. package/dist/components/chat-model-mode-utilities.js.map +1 -0
  170. package/dist/components/chat-models-utilities.d.ts +98 -0
  171. package/dist/components/chat-models-utilities.d.ts.map +1 -0
  172. package/dist/components/chat-models-utilities.js +136 -0
  173. package/dist/components/chat-models-utilities.js.map +1 -0
  174. package/dist/components/chat-report-utilities.d.ts +149 -0
  175. package/dist/components/chat-report-utilities.d.ts.map +1 -0
  176. package/dist/components/chat-report-utilities.js +151 -0
  177. package/dist/components/chat-report-utilities.js.map +1 -0
  178. package/dist/components/chat-scripts-utilities.d.ts +140 -0
  179. package/dist/components/chat-scripts-utilities.d.ts.map +1 -0
  180. package/dist/components/chat-scripts-utilities.js +158 -0
  181. package/dist/components/chat-scripts-utilities.js.map +1 -0
  182. package/dist/components/chat-settings-utilities.d.ts +72 -0
  183. package/dist/components/chat-settings-utilities.d.ts.map +1 -0
  184. package/dist/components/chat-settings-utilities.js +56 -0
  185. package/dist/components/chat-settings-utilities.js.map +1 -0
  186. package/dist/components/chat-share-utilities.d.ts +104 -0
  187. package/dist/components/chat-share-utilities.d.ts.map +1 -0
  188. package/dist/components/chat-share-utilities.js +90 -0
  189. package/dist/components/chat-share-utilities.js.map +1 -0
  190. package/dist/components/chat-skills-utilities.d.ts +242 -0
  191. package/dist/components/chat-skills-utilities.d.ts.map +1 -0
  192. package/dist/components/chat-skills-utilities.js +433 -0
  193. package/dist/components/chat-skills-utilities.js.map +1 -0
  194. package/dist/components/chat-stream-utilities.d.ts +74 -0
  195. package/dist/components/chat-stream-utilities.d.ts.map +1 -0
  196. package/dist/components/chat-stream-utilities.js +103 -0
  197. package/dist/components/chat-stream-utilities.js.map +1 -0
  198. package/dist/components/chat-tips-utilities.d.ts +108 -0
  199. package/dist/components/chat-tips-utilities.d.ts.map +1 -0
  200. package/dist/components/chat-tips-utilities.js +137 -0
  201. package/dist/components/chat-tips-utilities.js.map +1 -0
  202. package/dist/components/device-cycle.d.ts +84 -0
  203. package/dist/components/device-cycle.d.ts.map +1 -0
  204. package/dist/components/device-cycle.js +77 -0
  205. package/dist/components/device-cycle.js.map +1 -0
  206. package/dist/components/index.d.ts +36 -0
  207. package/dist/components/index.d.ts.map +1 -0
  208. package/dist/components/index.js +33 -0
  209. package/dist/components/index.js.map +1 -0
  210. package/dist/components/tool-call-utilities.d.ts +69 -0
  211. package/dist/components/tool-call-utilities.d.ts.map +1 -0
  212. package/dist/components/tool-call-utilities.js +368 -0
  213. package/dist/components/tool-call-utilities.js.map +1 -0
  214. package/dist/components/user-avatar-utilities.d.ts +41 -0
  215. package/dist/components/user-avatar-utilities.d.ts.map +1 -0
  216. package/dist/components/user-avatar-utilities.js +56 -0
  217. package/dist/components/user-avatar-utilities.js.map +1 -0
  218. package/dist/components/workspace-layout-utilities.d.ts +37 -0
  219. package/dist/components/workspace-layout-utilities.d.ts.map +1 -0
  220. package/dist/components/workspace-layout-utilities.js +58 -0
  221. package/dist/components/workspace-layout-utilities.js.map +1 -0
  222. package/dist/customEventCards.d.ts +117 -0
  223. package/dist/customEventCards.d.ts.map +1 -0
  224. package/dist/customEventCards.js +36 -0
  225. package/dist/customEventCards.js.map +1 -0
  226. package/dist/hooks/index.d.ts +8 -0
  227. package/dist/hooks/index.d.ts.map +1 -0
  228. package/dist/hooks/index.js +8 -0
  229. package/dist/hooks/index.js.map +1 -0
  230. package/dist/hooks/useKeyboardShortcuts.d.ts +48 -0
  231. package/dist/hooks/useKeyboardShortcuts.d.ts.map +1 -0
  232. package/dist/hooks/useKeyboardShortcuts.js +112 -0
  233. package/dist/hooks/useKeyboardShortcuts.js.map +1 -0
  234. package/dist/hooks/useViewport.d.ts +42 -0
  235. package/dist/hooks/useViewport.d.ts.map +1 -0
  236. package/dist/hooks/useViewport.js +61 -0
  237. package/dist/hooks/useViewport.js.map +1 -0
  238. package/dist/index.d.ts +75 -0
  239. package/dist/index.d.ts.map +1 -0
  240. package/dist/index.js +74 -0
  241. package/dist/index.js.map +1 -0
  242. package/dist/settings-metadata.d.ts +78 -0
  243. package/dist/settings-metadata.d.ts.map +1 -0
  244. package/dist/settings-metadata.js +131 -0
  245. package/dist/settings-metadata.js.map +1 -0
  246. package/dist/types.d.ts +751 -0
  247. package/dist/types.d.ts.map +1 -0
  248. package/dist/types.js +25 -0
  249. package/dist/types.js.map +1 -0
  250. package/package.json +90 -0
@@ -0,0 +1,1111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Tree-view file browser with multi-select, keyboard navigation, and drag-and-drop.
4
+ *
5
+ * @module
6
+ */
7
+ import { getIcon } from 'material-file-icons';
8
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { t } from '@molecule/app-i18n';
10
+ import { useThemeMode } from '@molecule/app-react';
11
+ import { getClassMap } from '@molecule/app-ui';
12
+ import { useCoarsePointer } from '../hooks/useViewport.js';
13
+ import { FileExplorerContextMenu } from './FileExplorerContextMenu.js';
14
+ // Git status → filename color — dark/light variants for readability
15
+ const DARK_GIT_COLORS = {
16
+ modified: '#e2c08d',
17
+ added: '#73c991',
18
+ untracked: '#73c991',
19
+ deleted: '#c74e39',
20
+ };
21
+ const LIGHT_GIT_COLORS = {
22
+ modified: '#8a6010',
23
+ added: '#1a7030',
24
+ untracked: '#1a7030',
25
+ deleted: '#a03020',
26
+ };
27
+ // Priority order for propagating the most important status to parent directories
28
+ const STATUS_PRIORITY = {
29
+ deleted: 3,
30
+ modified: 2,
31
+ added: 1,
32
+ untracked: 1,
33
+ };
34
+ /**
35
+ * Compute the highest-priority git status color for a directory by checking
36
+ * all entries in fileStatuses whose paths start with the directory path.
37
+ * @param dirPath - The directory path to check.
38
+ * @param fileStatuses - Map of file paths to git status strings.
39
+ * @param colors - Map of git status names to CSS color values.
40
+ * @returns The color string for the highest-priority child status, or undefined.
41
+ */
42
+ function getDirColor(dirPath, fileStatuses, colors) {
43
+ let bestPriority = 0;
44
+ let bestStatus;
45
+ const prefix = dirPath + '/';
46
+ for (const [path, status] of Object.entries(fileStatuses)) {
47
+ if (path.startsWith(prefix)) {
48
+ const p = STATUS_PRIORITY[status] ?? 0;
49
+ if (p > bestPriority) {
50
+ bestPriority = p;
51
+ bestStatus = status;
52
+ }
53
+ }
54
+ }
55
+ return bestStatus ? colors[bestStatus] : undefined;
56
+ }
57
+ /**
58
+ * Loads persisted expand/collapse state from localStorage.
59
+ * @param persistKey - The localStorage key to read from.
60
+ * @returns The deserialized expand state, or a fresh empty state if none found.
61
+ */
62
+ function loadExpandState(persistKey) {
63
+ try {
64
+ const raw = localStorage.getItem(persistKey);
65
+ if (raw) {
66
+ const parsed = JSON.parse(raw);
67
+ return { expanded: new Set(parsed.expanded), collapsed: new Set(parsed.collapsed) };
68
+ }
69
+ }
70
+ catch (_error) {
71
+ // Safe to ignore: corrupt/unparseable localStorage data — fall back to empty state
72
+ }
73
+ return { expanded: new Set(), collapsed: new Set() };
74
+ }
75
+ /**
76
+ * Persists expand/collapse state to localStorage.
77
+ * @param persistKey - The localStorage key to write to.
78
+ * @param state - The expand state to serialize and store.
79
+ */
80
+ function saveExpandState(persistKey, state) {
81
+ try {
82
+ localStorage.setItem(persistKey, JSON.stringify({ expanded: [...state.expanded], collapsed: [...state.collapsed] }));
83
+ }
84
+ catch (_error) {
85
+ // Safe to ignore: localStorage write can fail (quota exceeded, private browsing) — expand state simply won't persist
86
+ }
87
+ }
88
+ /**
89
+ * Determines whether a directory path should be expanded based on persisted state.
90
+ * @param state - The current expand/collapse state.
91
+ * @param path - The directory path to check.
92
+ * @param depth - The nesting depth (directories at depth 0 default to expanded).
93
+ * @returns True if the directory should be shown expanded.
94
+ */
95
+ function isExpandedFromState(state, path, depth) {
96
+ if (state.expanded.has(path))
97
+ return true;
98
+ if (state.collapsed.has(path))
99
+ return false;
100
+ return depth < 1;
101
+ }
102
+ /**
103
+ * Returns the ancestor directory paths for a given file path (e.g. "a/b/c.ts" → ["a", "a/b"]).
104
+ * @param filePath - The full file path to extract ancestors from.
105
+ * @returns An array of ancestor directory paths, from shallowest to deepest.
106
+ */
107
+ function getAncestorPaths(filePath) {
108
+ const parts = filePath.split('/');
109
+ const ancestors = [];
110
+ for (let i = 1; i < parts.length; i++) {
111
+ ancestors.push(parts.slice(0, i).join('/'));
112
+ }
113
+ return ancestors;
114
+ }
115
+ /**
116
+ * Find a node and its depth in the file tree by path.
117
+ * @param nodes - The tree nodes to search through.
118
+ * @param targetPath - The path to find.
119
+ * @param depth - The current tree depth (used for recursion).
120
+ * @returns The matching node and its depth, or null if not found.
121
+ */
122
+ function findNodeByPath(nodes, targetPath, depth = 0) {
123
+ for (const node of nodes) {
124
+ if (node.path === targetPath)
125
+ return { node, depth };
126
+ if (node.children) {
127
+ const found = findNodeByPath(node.children, targetPath, depth + 1);
128
+ if (found)
129
+ return found;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+ /**
135
+ * Resolve a symlink target to an absolute path relative to the symlink's parent directory.
136
+ * @param symlinkPath - The path of the symlink itself.
137
+ * @param target - The symlink's target value (relative or absolute).
138
+ * @returns The resolved absolute path of the symlink target.
139
+ */
140
+ function resolveSymlinkTarget(symlinkPath, target) {
141
+ if (target.startsWith('/'))
142
+ return target;
143
+ const parentDir = symlinkPath.slice(0, symlinkPath.lastIndexOf('/'));
144
+ return `${parentDir}/${target}`;
145
+ }
146
+ /**
147
+ * Scroll to and briefly highlight a tree item by path.
148
+ * @param container - The scrollable container element.
149
+ * @param path - The file path to scroll to and highlight.
150
+ */
151
+ function scrollAndHighlight(container, path) {
152
+ if (!container)
153
+ return;
154
+ const el = Array.from(container.querySelectorAll('[data-explorer-path]')).find((e) => e.dataset.explorerPath === path);
155
+ if (!el)
156
+ return;
157
+ el.scrollIntoView({ block: 'nearest' });
158
+ el.style.transition = 'background 0s';
159
+ el.style.background = 'rgba(128,128,128,0.25)';
160
+ requestAnimationFrame(() => {
161
+ el.style.transition = 'background 600ms ease-out';
162
+ el.style.background = '';
163
+ });
164
+ }
165
+ /**
166
+ * Sort nodes: directories first, then files, each group alphabetically.
167
+ * @param nodes - The nodes to sort.
168
+ * @returns A new sorted array.
169
+ */
170
+ function sortNodes(nodes) {
171
+ return [...nodes].sort((a, b) => {
172
+ if (a.type !== b.type)
173
+ return a.type === 'directory' ? -1 : 1;
174
+ return a.name.localeCompare(b.name);
175
+ });
176
+ }
177
+ /**
178
+ * Flatten the file tree into a list of visible paths respecting expand/collapse state.
179
+ * @param nodes - The tree nodes.
180
+ * @param expandState - Current expand/collapse state.
181
+ * @param depth - Current depth (for default expansion).
182
+ * @returns Ordered array of visible file paths.
183
+ */
184
+ function getVisibleItems(nodes, expandState, depth = 0) {
185
+ const result = [];
186
+ const sorted = sortNodes(nodes);
187
+ for (const node of sorted) {
188
+ result.push(node.path);
189
+ if (node.type === 'directory' &&
190
+ isExpandedFromState(expandState, node.path, depth) &&
191
+ node.children) {
192
+ result.push(...getVisibleItems(node.children, expandState, depth + 1));
193
+ }
194
+ }
195
+ return result;
196
+ }
197
+ /**
198
+ * Collect all paths in a tree (used for pruning stale selections).
199
+ * @param nodes - The tree nodes.
200
+ * @returns Set of all paths in the tree.
201
+ */
202
+ function collectAllPaths(nodes) {
203
+ const paths = new Set();
204
+ for (const node of nodes) {
205
+ paths.add(node.path);
206
+ if (node.children) {
207
+ for (const p of collectAllPaths(node.children))
208
+ paths.add(p);
209
+ }
210
+ }
211
+ return paths;
212
+ }
213
+ /**
214
+ * Get the parent directory path of a file path.
215
+ * @param path - The file path.
216
+ * @returns The parent directory path.
217
+ */
218
+ function getParentDir(path) {
219
+ const idx = path.lastIndexOf('/');
220
+ return idx > 0 ? path.slice(0, idx) : '/workspace';
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Icons
224
+ // ---------------------------------------------------------------------------
225
+ /**
226
+ * Chevron icon — points right when collapsed, down when expanded.
227
+ * @param props - Component props (see {@link FileTreeItemProps}).
228
+ * @returns The rendered SVG chevron icon.
229
+ */
230
+ function ChevronIcon({ isOpen }) {
231
+ return (_jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 16 16", width: "16", height: "16", style: {
232
+ display: 'block',
233
+ flexShrink: 0,
234
+ transform: isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
235
+ transition: 'transform 100ms',
236
+ opacity: 0.65,
237
+ }, children: _jsx("polyline", { points: "6,4 10,8 6,12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }));
238
+ }
239
+ /**
240
+ * File icon resolved from material-file-icons based on the filename.
241
+ *
242
+ * Uses dangerouslySetInnerHTML to render SVG from the material-file-icons library.
243
+ * This is safe because the SVG content comes from a trusted static icon library,
244
+ * not from user input.
245
+ * @param props - Component props (see {@link FileTreeItemProps}).
246
+ * @returns The rendered file type icon element.
247
+ */
248
+ function FileTypeIcon({ name }) {
249
+ const { svg } = getIcon(name);
250
+ return (_jsx("span", { style: { display: 'inline-flex', flexShrink: 0, width: '16px', height: '16px' }, dangerouslySetInnerHTML: { __html: svg } }));
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // Long-press → context menu (touch)
254
+ // ---------------------------------------------------------------------------
255
+ /** How long a touch must hold still before it counts as a long-press. */
256
+ const LONG_PRESS_MS = 500;
257
+ /** Finger drift beyond this many pixels cancels a pending long-press. */
258
+ const LONG_PRESS_MOVE_TOLERANCE_PX = 10;
259
+ /**
260
+ * Recognizes a touch long-press (~500ms hold within a ~10px radius) and invokes
261
+ * `onLongPress` with the press position — the same `{ x, y }` shape the
262
+ * `contextmenu` handlers take. iOS Safari never fires the `contextmenu` DOM
263
+ * event, so this is the only path to the file-explorer context menu on touch
264
+ * devices. Mouse pointers are ignored entirely (they right-click), so desktop
265
+ * behavior is untouched; pass `enabled: false` (fine pointer) to disable it.
266
+ * @param enabled - Whether the recognizer is active (coarse pointers only).
267
+ * @param onLongPress - Invoked with the press position when the hold fires.
268
+ * @returns Handlers to spread on the target element (see {@link LongPressHandlers}).
269
+ */
270
+ function useLongPress(enabled, onLongPress) {
271
+ const timerRef = useRef(null);
272
+ const startRef = useRef(null);
273
+ const firedRef = useRef(false);
274
+ // Latest-callback ref so the timer never fires a stale closure (node/props change).
275
+ const onLongPressRef = useRef(onLongPress);
276
+ onLongPressRef.current = onLongPress;
277
+ const clear = useCallback(() => {
278
+ if (timerRef.current !== null) {
279
+ window.clearTimeout(timerRef.current);
280
+ timerRef.current = null;
281
+ }
282
+ startRef.current = null;
283
+ }, []);
284
+ // Never let a pending timer fire after unmount.
285
+ useEffect(() => clear, [clear]);
286
+ const onPointerDown = useCallback((e) => {
287
+ // A new press always invalidates any stale "just fired" flag.
288
+ firedRef.current = false;
289
+ if (!enabled || e.pointerType === 'mouse')
290
+ return;
291
+ const { clientX, clientY } = e;
292
+ clear();
293
+ startRef.current = { x: clientX, y: clientY };
294
+ timerRef.current = window.setTimeout(() => {
295
+ timerRef.current = null;
296
+ firedRef.current = true;
297
+ onLongPressRef.current({ x: clientX, y: clientY });
298
+ }, LONG_PRESS_MS);
299
+ }, [enabled, clear]);
300
+ const onPointerMove = useCallback((e) => {
301
+ if (timerRef.current === null || !startRef.current)
302
+ return;
303
+ const dx = e.clientX - startRef.current.x;
304
+ const dy = e.clientY - startRef.current.y;
305
+ if (dx * dx + dy * dy > LONG_PRESS_MOVE_TOLERANCE_PX * LONG_PRESS_MOVE_TOLERANCE_PX) {
306
+ clear();
307
+ }
308
+ }, [clear]);
309
+ const onTouchEnd = useCallback((e) => {
310
+ if (firedRef.current)
311
+ e.preventDefault();
312
+ }, []);
313
+ const consumeFired = useCallback(() => {
314
+ const fired = firedRef.current;
315
+ firedRef.current = false;
316
+ return fired;
317
+ }, []);
318
+ return {
319
+ onPointerDown,
320
+ onPointerMove,
321
+ onPointerUp: clear,
322
+ onPointerCancel: clear,
323
+ onTouchEnd,
324
+ consumeFired,
325
+ };
326
+ }
327
+ // ---------------------------------------------------------------------------
328
+ // FileTreeItem
329
+ // ---------------------------------------------------------------------------
330
+ /**
331
+ * Max total FileTreeItems to render in a single FileExplorer pass.
332
+ * Prevents symlink cycles from freezing the browser without imposing
333
+ * any depth limit on legitimate projects.
334
+ */
335
+ const MAX_RENDERED_NODES = 10_000;
336
+ /**
337
+ * Recursive tree item for a single file or directory node.
338
+ * @param props - Component props.
339
+ * @returns The rendered tree item element.
340
+ */
341
+ const FileTreeItem = memo(function FileTreeItem({ node, depth, isExpanded, activeFile, isSelected, isCut, isFocused, isDropTarget, onFileSelect, onFileDoubleClick, onDirExpand, onTogglePath, onSymlinkClick, onContextMenu, onItemClick, onDragStart, onDragOver, onDragLeave, onDrop, expandState, fileStatuses, gitColors, selectedPaths, clipboardPaths, clipboardOperation, renderCount, dropTargetPath, isCoarse, }) {
342
+ renderCount.current++;
343
+ const cm = getClassMap();
344
+ const isDir = node.type === 'directory';
345
+ // Load children whenever this dir is expanded but hasn't been fetched yet.
346
+ // `undefined` means not yet loaded; `[]` means loaded but empty — only fetch
347
+ // for the former to avoid infinite loops on empty/errored directories.
348
+ useEffect(() => {
349
+ if (isExpanded && isDir && node.children === undefined && onDirExpand) {
350
+ onDirExpand(node.path);
351
+ }
352
+ }, [isExpanded, isDir, node.children, node.path, onDirExpand]);
353
+ // iOS Safari never fires 'contextmenu', so on coarse pointers a ~500ms
354
+ // long-press opens the SAME menu the right-click path does. (The handlers
355
+ // returned by the hook are referentially stable across renders.)
356
+ const { onPointerDown: onLongPressPointerDown, onPointerMove: onLongPressPointerMove, onPointerUp: onLongPressPointerUp, onPointerCancel: onLongPressPointerCancel, onTouchEnd: onLongPressTouchEnd, consumeFired: consumeLongPressFired, } = useLongPress(isCoarse && !!onContextMenu, (position) => {
357
+ onContextMenu?.(position, node);
358
+ });
359
+ const handleClick = useCallback((e) => {
360
+ // A fired long-press must not ALSO select/open the item — the click the
361
+ // browser synthesizes at finger-lift is consumed here.
362
+ if (consumeLongPressFired())
363
+ return;
364
+ const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey;
365
+ if (hasModifier) {
366
+ // Modifier clicks: pure selection, no open/toggle
367
+ onItemClick(node.path, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
368
+ }
369
+ else {
370
+ // No modifier: select single + perform action
371
+ onItemClick(node.path, { ctrlKey: false, metaKey: false, shiftKey: false });
372
+ if (node.symlinkTarget && onSymlinkClick) {
373
+ onSymlinkClick(node.path, node.symlinkTarget);
374
+ }
375
+ else if (isDir) {
376
+ onTogglePath(node.path, !isExpanded);
377
+ }
378
+ else {
379
+ onFileSelect(node.path);
380
+ }
381
+ }
382
+ }, [
383
+ node.path,
384
+ node.symlinkTarget,
385
+ isDir,
386
+ isExpanded,
387
+ onItemClick,
388
+ onFileSelect,
389
+ onTogglePath,
390
+ onSymlinkClick,
391
+ consumeLongPressFired,
392
+ ]);
393
+ // Directories before files, each group sorted alphabetically
394
+ const sortedChildren = node.children ? sortNodes(node.children) : undefined;
395
+ // Compute background color: dropTarget > selection > activeFile > transparent
396
+ let background = 'transparent';
397
+ if (isDropTarget) {
398
+ background = 'rgba(0,100,200,0.15)';
399
+ }
400
+ else if (isSelected) {
401
+ background = 'rgba(0,100,200,0.2)';
402
+ }
403
+ else if (node.path === activeFile) {
404
+ background = 'rgba(128,128,128,0.15)';
405
+ }
406
+ // Compute opacity: cut > dimmed > normal
407
+ let opacity = 1;
408
+ if (isCut) {
409
+ opacity = 0.4;
410
+ }
411
+ else if (node.isDimmed) {
412
+ opacity = 0.35;
413
+ }
414
+ return (_jsxs("div", { children: [_jsxs("button", { type: "button", onClick: handleClick, onDoubleClick: () => {
415
+ if (!isDir && onFileDoubleClick)
416
+ onFileDoubleClick(node.path);
417
+ }, onContextMenu: (e) => {
418
+ if (onContextMenu) {
419
+ e.preventDefault();
420
+ e.stopPropagation();
421
+ onContextMenu({ x: e.clientX, y: e.clientY }, node);
422
+ }
423
+ }, onPointerDown: onLongPressPointerDown, onPointerMove: onLongPressPointerMove, onPointerUp: onLongPressPointerUp, onPointerCancel: onLongPressPointerCancel, onTouchEnd: onLongPressTouchEnd,
424
+ // On touch devices a draggable element hijacks the long-press to start a
425
+ // native drag (firing pointercancel, which kills the context-menu timer),
426
+ // so rows are only draggable on fine pointers. Desktop drag-move is untouched.
427
+ draggable: !isCoarse, onDragStart: (e) => onDragStart(node.path, e), onDragOver: (e) => onDragOver(node.path, e), onDragLeave: onDragLeave, onDrop: (e) => onDrop(node.path, e), "data-explorer-path": node.path, className: cm.w('full'), style: {
428
+ display: 'flex',
429
+ alignItems: 'center',
430
+ gap: '4px',
431
+ paddingLeft: `${depth * 12}px`,
432
+ paddingTop: '1px',
433
+ paddingBottom: '1px',
434
+ paddingRight: '6px',
435
+ // ≥36px rows on touch-first devices (the agreed floor for dense file
436
+ // trees — chevron + row are then comfortably tappable); the compact
437
+ // ~24px pointer row is unchanged.
438
+ minHeight: isCoarse ? '36px' : undefined,
439
+ // Keep the native text-selection/callout from fighting the long-press
440
+ // gesture on touch devices; fine-pointer rows are untouched.
441
+ WebkitUserSelect: isCoarse ? 'none' : undefined,
442
+ userSelect: isCoarse ? 'none' : undefined,
443
+ WebkitTouchCallout: isCoarse ? 'none' : undefined,
444
+ border: 'none',
445
+ background,
446
+ outline: isDropTarget
447
+ ? '1px solid rgba(0,100,200,0.4)'
448
+ : isFocused
449
+ ? '1px dotted rgba(128,128,128,0.5)'
450
+ : 'none',
451
+ outlineOffset: '-1px',
452
+ color: 'inherit',
453
+ cursor: 'pointer',
454
+ textAlign: 'left',
455
+ opacity,
456
+ fontSize: '13px',
457
+ lineHeight: '22px',
458
+ }, children: [_jsx("span", { style: { display: 'flex', alignItems: 'center', width: '16px', flexShrink: 0 }, children: isDir ? _jsx(ChevronIcon, { isOpen: isExpanded }) : _jsx(FileTypeIcon, { name: node.name }) }), _jsxs("span", { style: {
459
+ overflow: 'hidden',
460
+ textOverflow: 'ellipsis',
461
+ whiteSpace: 'nowrap',
462
+ color: isDir
463
+ ? fileStatuses
464
+ ? getDirColor(node.path, fileStatuses, gitColors)
465
+ : undefined
466
+ : fileStatuses?.[node.path]
467
+ ? gitColors[fileStatuses[node.path]]
468
+ : undefined,
469
+ opacity: node.symlinkTarget ? 0.55 : undefined,
470
+ }, children: [node.name, node.symlinkTarget && ' \u2192 ' + node.symlinkTarget.split('/').pop()] })] }), isDir && isExpanded && sortedChildren && renderCount.current < MAX_RENDERED_NODES && (_jsxs("div", { style: { position: 'relative' }, children: [_jsx("span", { style: {
471
+ position: 'absolute',
472
+ left: `${depth * 12 + 8}px`,
473
+ top: 0,
474
+ bottom: 0,
475
+ width: '1px',
476
+ background: 'rgba(128,128,128,0.15)',
477
+ pointerEvents: 'none',
478
+ } }), sortedChildren.map((child) => (_jsx(FileTreeItem, { node: child, depth: depth + 1, isExpanded: isExpandedFromState(expandState, child.path, depth + 1), activeFile: activeFile, isSelected: selectedPaths.has(child.path), isCut: clipboardOperation === 'cut' &&
479
+ clipboardPaths !== null &&
480
+ clipboardPaths.has(child.path), isFocused: false, isDropTarget: dropTargetPath === child.path, onFileSelect: onFileSelect, onFileDoubleClick: onFileDoubleClick, onDirExpand: onDirExpand, onTogglePath: onTogglePath, onSymlinkClick: onSymlinkClick, onContextMenu: onContextMenu, onItemClick: onItemClick, onDragStart: onDragStart, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, expandState: expandState, fileStatuses: fileStatuses, gitColors: gitColors, selectedPaths: selectedPaths, clipboardPaths: clipboardPaths, clipboardOperation: clipboardOperation, renderCount: renderCount, dropTargetPath: dropTargetPath, isCoarse: isCoarse }, child.path)))] }))] }));
481
+ });
482
+ // ---------------------------------------------------------------------------
483
+ // FileExplorer
484
+ // ---------------------------------------------------------------------------
485
+ /**
486
+ * Tree-view file explorer component with multi-select, keyboard navigation, and drag-and-drop.
487
+ * @param props - Component props (see {@link FileTreeItemProps}).
488
+ * @returns The rendered file explorer element.
489
+ */
490
+ export function FileExplorer({ files, onFileSelect, onFileDoubleClick, onDirExpand, onRename, onDelete, onDeleteMultiple, onMoveFiles, onNewFile, onNewFolder, onCollapseAll, className, persistKey, activeFile, fileStatuses, }) {
491
+ const cm = getClassMap();
492
+ const gitColors = useThemeMode() === 'light' ? LIGHT_GIT_COLORS : DARK_GIT_COLORS;
493
+ const isCoarse = useCoarsePointer();
494
+ const containerRef = useRef(null);
495
+ // When set, the next render after DOM update will scroll this path into view
496
+ const scrollTargetRef = useRef(null);
497
+ // When set, briefly flash a highlight on this path after scrolling
498
+ const highlightTargetRef = useRef(null);
499
+ const [expandState, setExpandState] = useState(() => persistKey ? loadExpandState(persistKey) : { expanded: new Set(), collapsed: new Set() });
500
+ // ---------------------------------------------------------------------------
501
+ // Selection state
502
+ // ---------------------------------------------------------------------------
503
+ const [selectedPaths, setSelectedPaths] = useState(new Set());
504
+ const [lastClickedPath, setLastClickedPath] = useState(null);
505
+ const [focusedPath, setFocusedPath] = useState(null);
506
+ // Clipboard for cut/copy (internal)
507
+ const [clipboard, setClipboard] = useState(null);
508
+ // Drag-and-drop state
509
+ const [draggedPaths, setDraggedPaths] = useState(null);
510
+ const [dropTargetPath, setDropTargetPath] = useState(null);
511
+ // Memoized visible items list — used for shift-click range and keyboard nav
512
+ const visibleItems = useMemo(() => getVisibleItems(files, expandState), [files, expandState]);
513
+ // Prune stale selections when the file tree changes
514
+ useEffect(() => {
515
+ const treePaths = collectAllPaths(files);
516
+ setSelectedPaths((prev) => {
517
+ const next = new Set();
518
+ for (const p of prev) {
519
+ if (treePaths.has(p))
520
+ next.add(p);
521
+ }
522
+ return next.size === prev.size ? prev : next;
523
+ });
524
+ }, [files]);
525
+ // When activeFile changes, expand all ancestor directories and queue a scroll
526
+ useEffect(() => {
527
+ if (!activeFile)
528
+ return;
529
+ const ancestors = getAncestorPaths(activeFile);
530
+ if (ancestors.length > 0) {
531
+ setExpandState((prev) => {
532
+ const needsUpdate = ancestors.some((p) => !prev.expanded.has(p));
533
+ if (!needsUpdate)
534
+ return prev;
535
+ const next = {
536
+ expanded: new Set(prev.expanded),
537
+ collapsed: new Set(prev.collapsed),
538
+ };
539
+ for (const ancestor of ancestors) {
540
+ next.expanded.add(ancestor);
541
+ next.collapsed.delete(ancestor);
542
+ }
543
+ if (persistKey)
544
+ saveExpandState(persistKey, next);
545
+ return next;
546
+ });
547
+ }
548
+ scrollTargetRef.current = activeFile;
549
+ }, [activeFile, persistKey]);
550
+ // After every render, attempt the pending scroll (retries until element is in the DOM)
551
+ useEffect(() => {
552
+ if (!scrollTargetRef.current || !containerRef.current)
553
+ return;
554
+ const target = scrollTargetRef.current;
555
+ const shouldHighlight = highlightTargetRef.current === target;
556
+ const el = Array.from(containerRef.current.querySelectorAll('[data-explorer-path]')).find((e) => e.dataset.explorerPath === target);
557
+ if (el) {
558
+ scrollTargetRef.current = null;
559
+ if (shouldHighlight) {
560
+ highlightTargetRef.current = null;
561
+ scrollAndHighlight(containerRef.current, target);
562
+ }
563
+ else {
564
+ el.scrollIntoView({ block: 'nearest' });
565
+ }
566
+ }
567
+ });
568
+ const handleTogglePath = useCallback((path, nowExpanded) => {
569
+ setExpandState((prev) => {
570
+ const next = {
571
+ expanded: new Set(prev.expanded),
572
+ collapsed: new Set(prev.collapsed),
573
+ };
574
+ if (nowExpanded) {
575
+ next.expanded.add(path);
576
+ next.collapsed.delete(path);
577
+ }
578
+ else {
579
+ next.collapsed.add(path);
580
+ next.expanded.delete(path);
581
+ }
582
+ if (persistKey)
583
+ saveExpandState(persistKey, next);
584
+ return next;
585
+ });
586
+ }, [persistKey]);
587
+ const handleSymlinkClick = useCallback((symlinkPath, target) => {
588
+ const resolvedPath = resolveSymlinkTarget(symlinkPath, target);
589
+ const found = findNodeByPath(files, resolvedPath);
590
+ if (found && found.node.type === 'directory') {
591
+ // Ensure ancestors are expanded so the target is visible
592
+ const ancestors = getAncestorPaths(resolvedPath);
593
+ setExpandState((prev) => {
594
+ const needsUpdate = ancestors.some((p) => !prev.expanded.has(p));
595
+ if (!needsUpdate) {
596
+ // Already visible — scroll and highlight directly
597
+ scrollAndHighlight(containerRef.current, resolvedPath);
598
+ return prev;
599
+ }
600
+ const next = {
601
+ expanded: new Set(prev.expanded),
602
+ collapsed: new Set(prev.collapsed),
603
+ };
604
+ for (const a of ancestors) {
605
+ next.expanded.add(a);
606
+ next.collapsed.delete(a);
607
+ }
608
+ if (persistKey)
609
+ saveExpandState(persistKey, next);
610
+ return next;
611
+ });
612
+ scrollTargetRef.current = resolvedPath;
613
+ highlightTargetRef.current = resolvedPath;
614
+ }
615
+ else {
616
+ // File symlink (or target not in tree) — select the real file
617
+ onFileSelect(resolvedPath);
618
+ }
619
+ }, [files, persistKey, onFileSelect]);
620
+ // ---------------------------------------------------------------------------
621
+ // Selection click handler
622
+ // ---------------------------------------------------------------------------
623
+ const handleItemClick = useCallback((path, modifiers) => {
624
+ const isToggle = modifiers.ctrlKey || modifiers.metaKey;
625
+ if (modifiers.shiftKey && lastClickedPath) {
626
+ // Shift+Click: range select
627
+ const startIdx = visibleItems.indexOf(lastClickedPath);
628
+ const endIdx = visibleItems.indexOf(path);
629
+ if (startIdx !== -1 && endIdx !== -1) {
630
+ const lo = Math.min(startIdx, endIdx);
631
+ const hi = Math.max(startIdx, endIdx);
632
+ const range = new Set(visibleItems.slice(lo, hi + 1));
633
+ // When shift is combined with ctrl/cmd, add to existing selection
634
+ if (isToggle) {
635
+ setSelectedPaths((prev) => {
636
+ const next = new Set(prev);
637
+ for (const p of range)
638
+ next.add(p);
639
+ return next;
640
+ });
641
+ }
642
+ else {
643
+ setSelectedPaths(range);
644
+ }
645
+ }
646
+ setFocusedPath(path);
647
+ // Don't update lastClickedPath on shift-click to preserve the anchor
648
+ }
649
+ else if (isToggle) {
650
+ // Ctrl/Cmd+Click: toggle single item
651
+ setSelectedPaths((prev) => {
652
+ const next = new Set(prev);
653
+ if (next.has(path)) {
654
+ next.delete(path);
655
+ }
656
+ else {
657
+ next.add(path);
658
+ }
659
+ return next;
660
+ });
661
+ setLastClickedPath(path);
662
+ setFocusedPath(path);
663
+ }
664
+ else {
665
+ // Plain click: select single
666
+ setSelectedPaths(new Set([path]));
667
+ setLastClickedPath(path);
668
+ setFocusedPath(path);
669
+ }
670
+ }, [lastClickedPath, visibleItems]);
671
+ // ---------------------------------------------------------------------------
672
+ // Context menu
673
+ // ---------------------------------------------------------------------------
674
+ const [contextMenu, setContextMenu] = useState(null);
675
+ const handleItemContextMenu = useCallback((position, node) => {
676
+ // If right-clicked item is already selected, keep multi-selection
677
+ if (!selectedPaths.has(node.path)) {
678
+ setSelectedPaths(new Set([node.path]));
679
+ setLastClickedPath(node.path);
680
+ }
681
+ setFocusedPath(node.path);
682
+ setContextMenu({ position, node });
683
+ }, [selectedPaths]);
684
+ // Shared by right-click AND touch long-press — the long-press path has no
685
+ // MouseEvent to hand over, only a position, so the opener takes the position.
686
+ const openBackgroundContextMenu = useCallback((position) => {
687
+ setSelectedPaths(new Set());
688
+ setContextMenu({ position, node: null });
689
+ }, []);
690
+ const handleBackgroundContextMenu = useCallback((e) => {
691
+ e.preventDefault();
692
+ openBackgroundContextMenu({ x: e.clientX, y: e.clientY });
693
+ }, [openBackgroundContextMenu]);
694
+ // Touch path to the background menu (New File / New Folder / Collapse All) —
695
+ // iOS Safari never fires 'contextmenu', so a long-press on the empty tree
696
+ // area opens it instead. Presses that start on a row are excluded at the
697
+ // container's onPointerDown (the row recognizer owns those).
698
+ const backgroundLongPress = useLongPress(isCoarse, openBackgroundContextMenu);
699
+ const handleCloseContextMenu = useCallback(() => {
700
+ setContextMenu(null);
701
+ }, []);
702
+ const handleCollapseAll = useCallback(() => {
703
+ setExpandState({ expanded: new Set(), collapsed: new Set(['__all_collapsed__']) });
704
+ if (persistKey)
705
+ saveExpandState(persistKey, {
706
+ expanded: new Set(),
707
+ collapsed: new Set(['__all_collapsed__']),
708
+ });
709
+ onCollapseAll?.();
710
+ }, [persistKey, onCollapseAll]);
711
+ /**
712
+ * Execute a paste operation: move clipboard items to the target directory.
713
+ * @param targetDir - The directory to move items into.
714
+ */
715
+ const executePaste = useCallback((targetDir) => {
716
+ if (!clipboard || clipboard.operation !== 'cut')
717
+ return;
718
+ const moves = [];
719
+ for (const oldPath of clipboard.paths) {
720
+ const fileName = oldPath.split('/').pop();
721
+ const newPath = `${targetDir}/${fileName}`;
722
+ if (newPath !== oldPath) {
723
+ moves.push({ oldPath, newPath });
724
+ }
725
+ }
726
+ if (moves.length > 0)
727
+ onMoveFiles?.(moves);
728
+ setClipboard(null);
729
+ setSelectedPaths(new Set());
730
+ }, [clipboard, onMoveFiles]);
731
+ const handleContextMenuAction = useCallback((action) => {
732
+ const node = contextMenu?.node;
733
+ switch (action) {
734
+ case 'open':
735
+ if (node)
736
+ onFileSelect(node.path);
737
+ break;
738
+ case 'rename':
739
+ if (node)
740
+ onRename?.(node.path);
741
+ break;
742
+ case 'delete':
743
+ if (node)
744
+ onDelete?.(node.path);
745
+ break;
746
+ case 'deleteMultiple':
747
+ if (selectedPaths.size > 0)
748
+ onDeleteMultiple?.(Array.from(selectedPaths));
749
+ break;
750
+ case 'newFile': {
751
+ const dir = node?.type === 'directory' ? node.path : '/workspace';
752
+ onNewFile?.(dir);
753
+ break;
754
+ }
755
+ case 'newFolder': {
756
+ const dir = node?.type === 'directory' ? node.path : '/workspace';
757
+ onNewFolder?.(dir);
758
+ break;
759
+ }
760
+ case 'copyPath':
761
+ if (node)
762
+ navigator.clipboard.writeText(node.path).catch(() => { });
763
+ break;
764
+ case 'copyPaths': {
765
+ const paths = Array.from(selectedPaths).join('\n');
766
+ navigator.clipboard.writeText(paths).catch(() => { });
767
+ break;
768
+ }
769
+ case 'copyRelativePath':
770
+ if (node) {
771
+ const rel = node.path.replace(/^\/workspace\//, '');
772
+ navigator.clipboard.writeText(rel).catch(() => { });
773
+ }
774
+ break;
775
+ case 'copyRelativePaths': {
776
+ const rels = Array.from(selectedPaths)
777
+ .map((p) => p.replace(/^\/workspace\//, ''))
778
+ .join('\n');
779
+ navigator.clipboard.writeText(rels).catch(() => { });
780
+ break;
781
+ }
782
+ case 'cut':
783
+ if (selectedPaths.size > 0) {
784
+ setClipboard({ paths: new Set(selectedPaths), operation: 'cut' });
785
+ navigator.clipboard.writeText(Array.from(selectedPaths).join('\n')).catch(() => { });
786
+ }
787
+ break;
788
+ case 'paste': {
789
+ // Paste into the right-clicked directory, or the directory of the right-clicked file
790
+ const targetDir = node
791
+ ? node.type === 'directory'
792
+ ? node.path
793
+ : getParentDir(node.path)
794
+ : '/workspace';
795
+ executePaste(targetDir);
796
+ break;
797
+ }
798
+ case 'collapseAll':
799
+ handleCollapseAll();
800
+ break;
801
+ }
802
+ }, [
803
+ contextMenu,
804
+ selectedPaths,
805
+ onFileSelect,
806
+ onRename,
807
+ onDelete,
808
+ onDeleteMultiple,
809
+ onNewFile,
810
+ onNewFolder,
811
+ handleCollapseAll,
812
+ executePaste,
813
+ ]);
814
+ // ---------------------------------------------------------------------------
815
+ // Keyboard navigation
816
+ // ---------------------------------------------------------------------------
817
+ const handleTreeKeyDown = useCallback((e) => {
818
+ const isMod = e.metaKey || e.ctrlKey;
819
+ // Navigate up/down
820
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
821
+ e.preventDefault();
822
+ const currentIdx = focusedPath ? visibleItems.indexOf(focusedPath) : -1;
823
+ const nextIdx = e.key === 'ArrowDown'
824
+ ? Math.min(currentIdx + 1, visibleItems.length - 1)
825
+ : Math.max(currentIdx - 1, 0);
826
+ if (nextIdx < 0 || nextIdx >= visibleItems.length)
827
+ return;
828
+ const nextPath = visibleItems[nextIdx];
829
+ setFocusedPath(nextPath);
830
+ if (e.shiftKey) {
831
+ // Shift+Arrow: extend selection
832
+ setSelectedPaths((prev) => {
833
+ const next = new Set(prev);
834
+ next.add(nextPath);
835
+ return next;
836
+ });
837
+ }
838
+ else if (!isMod) {
839
+ setSelectedPaths(new Set([nextPath]));
840
+ setLastClickedPath(nextPath);
841
+ }
842
+ // Scroll focused item into view
843
+ if (containerRef.current) {
844
+ const el = containerRef.current.querySelector(`[data-explorer-path="${CSS.escape(nextPath)}"]`);
845
+ el?.scrollIntoView({ block: 'nearest' });
846
+ }
847
+ return;
848
+ }
849
+ // Expand directory / move to first child
850
+ if (e.key === 'ArrowRight' && focusedPath) {
851
+ e.preventDefault();
852
+ const found = findNodeByPath(files, focusedPath);
853
+ if (found?.node.type === 'directory') {
854
+ if (!isExpandedFromState(expandState, focusedPath, found.depth)) {
855
+ handleTogglePath(focusedPath, true);
856
+ }
857
+ else if (found.node.children && found.node.children.length > 0) {
858
+ const sorted = sortNodes(found.node.children);
859
+ setFocusedPath(sorted[0].path);
860
+ setSelectedPaths(new Set([sorted[0].path]));
861
+ setLastClickedPath(sorted[0].path);
862
+ }
863
+ }
864
+ return;
865
+ }
866
+ // Collapse directory / move to parent
867
+ if (e.key === 'ArrowLeft' && focusedPath) {
868
+ e.preventDefault();
869
+ const found = findNodeByPath(files, focusedPath);
870
+ if (found?.node.type === 'directory' &&
871
+ isExpandedFromState(expandState, focusedPath, found.depth)) {
872
+ handleTogglePath(focusedPath, false);
873
+ }
874
+ else {
875
+ const parent = getParentDir(focusedPath);
876
+ if (parent !== '/workspace' && parent.startsWith('/workspace')) {
877
+ setFocusedPath(parent);
878
+ setSelectedPaths(new Set([parent]));
879
+ setLastClickedPath(parent);
880
+ }
881
+ }
882
+ return;
883
+ }
884
+ // Enter: open file / toggle directory
885
+ if (e.key === 'Enter' && focusedPath) {
886
+ e.preventDefault();
887
+ const found = findNodeByPath(files, focusedPath);
888
+ if (found?.node.type === 'directory') {
889
+ handleTogglePath(focusedPath, !isExpandedFromState(expandState, focusedPath, found.depth));
890
+ }
891
+ else {
892
+ onFileSelect(focusedPath);
893
+ }
894
+ return;
895
+ }
896
+ // Space: toggle selection on focused item
897
+ if (e.key === ' ' && focusedPath) {
898
+ e.preventDefault();
899
+ setSelectedPaths((prev) => {
900
+ const next = new Set(prev);
901
+ if (next.has(focusedPath)) {
902
+ next.delete(focusedPath);
903
+ }
904
+ else {
905
+ next.add(focusedPath);
906
+ }
907
+ return next;
908
+ });
909
+ return;
910
+ }
911
+ // Delete/Backspace: delete selected
912
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPaths.size > 0) {
913
+ e.preventDefault();
914
+ if (selectedPaths.size === 1) {
915
+ onDelete?.(Array.from(selectedPaths)[0]);
916
+ }
917
+ else {
918
+ onDeleteMultiple?.(Array.from(selectedPaths));
919
+ }
920
+ return;
921
+ }
922
+ // F2: rename (single selection only)
923
+ if (e.key === 'F2' && selectedPaths.size === 1) {
924
+ e.preventDefault();
925
+ onRename?.(Array.from(selectedPaths)[0]);
926
+ return;
927
+ }
928
+ // Escape: clear selection + clipboard
929
+ if (e.key === 'Escape') {
930
+ e.preventDefault();
931
+ setSelectedPaths(new Set());
932
+ setClipboard(null);
933
+ setFocusedPath(null);
934
+ return;
935
+ }
936
+ // Ctrl/Cmd+A: select all visible
937
+ if (isMod && e.key === 'a') {
938
+ e.preventDefault();
939
+ setSelectedPaths(new Set(visibleItems));
940
+ return;
941
+ }
942
+ // Ctrl/Cmd+C: copy paths
943
+ if (isMod && e.key === 'c' && selectedPaths.size > 0) {
944
+ e.preventDefault();
945
+ const paths = Array.from(selectedPaths).join('\n');
946
+ navigator.clipboard.writeText(paths).catch(() => { });
947
+ setClipboard({ paths: new Set(selectedPaths), operation: 'copy' });
948
+ return;
949
+ }
950
+ // Ctrl/Cmd+X: cut
951
+ if (isMod && e.key === 'x' && selectedPaths.size > 0) {
952
+ e.preventDefault();
953
+ setClipboard({ paths: new Set(selectedPaths), operation: 'cut' });
954
+ navigator.clipboard.writeText(Array.from(selectedPaths).join('\n')).catch(() => { });
955
+ return;
956
+ }
957
+ // Ctrl/Cmd+V: paste (move cut items to focused directory)
958
+ if (isMod && e.key === 'v' && clipboard?.operation === 'cut') {
959
+ e.preventDefault();
960
+ let targetDir = '/workspace';
961
+ if (focusedPath) {
962
+ const found = findNodeByPath(files, focusedPath);
963
+ targetDir = found?.node.type === 'directory' ? focusedPath : getParentDir(focusedPath);
964
+ }
965
+ executePaste(targetDir);
966
+ return;
967
+ }
968
+ }, [
969
+ focusedPath,
970
+ visibleItems,
971
+ files,
972
+ expandState,
973
+ selectedPaths,
974
+ clipboard,
975
+ handleTogglePath,
976
+ onFileSelect,
977
+ onDelete,
978
+ onDeleteMultiple,
979
+ onRename,
980
+ executePaste,
981
+ ]);
982
+ // ---------------------------------------------------------------------------
983
+ // Drag and drop
984
+ // ---------------------------------------------------------------------------
985
+ const handleDragStart = useCallback((path, e) => {
986
+ let paths;
987
+ if (selectedPaths.has(path)) {
988
+ paths = new Set(selectedPaths);
989
+ }
990
+ else {
991
+ // Dragging unselected item — select it alone
992
+ paths = new Set([path]);
993
+ setSelectedPaths(paths);
994
+ }
995
+ setDraggedPaths(paths);
996
+ e.dataTransfer.setData('text/plain', [...paths].join('\n'));
997
+ e.dataTransfer.effectAllowed = 'move';
998
+ // Custom drag image
999
+ const badge = document.createElement('div');
1000
+ badge.textContent = paths.size === 1 ? (path.split('/').pop() ?? '') : `${paths.size} items`;
1001
+ badge.style.cssText =
1002
+ 'padding:4px 8px;background:#2a6fc9;color:#fff;border-radius:4px;font-size:12px;position:absolute;top:-1000px';
1003
+ document.body.appendChild(badge);
1004
+ e.dataTransfer.setDragImage(badge, 0, 0);
1005
+ requestAnimationFrame(() => {
1006
+ if (badge.parentNode)
1007
+ document.body.removeChild(badge);
1008
+ });
1009
+ }, [selectedPaths]);
1010
+ const handleDragOver = useCallback((path, e) => {
1011
+ if (!draggedPaths)
1012
+ return;
1013
+ e.preventDefault();
1014
+ e.stopPropagation();
1015
+ // Resolve drop target to a directory
1016
+ const found = findNodeByPath(files, path);
1017
+ const targetDir = found?.node.type === 'directory' ? path : getParentDir(path);
1018
+ // Validate: cannot drop into self or descendant of any dragged path
1019
+ for (const dragPath of draggedPaths) {
1020
+ if (targetDir === dragPath || targetDir.startsWith(dragPath + '/')) {
1021
+ e.dataTransfer.dropEffect = 'none';
1022
+ setDropTargetPath(null);
1023
+ return;
1024
+ }
1025
+ }
1026
+ e.dataTransfer.dropEffect = 'move';
1027
+ setDropTargetPath(targetDir);
1028
+ }, [draggedPaths, files]);
1029
+ const handleItemDragLeave = useCallback((e) => {
1030
+ // Only clear if we're truly leaving (not entering a child element)
1031
+ const related = e.relatedTarget;
1032
+ if (containerRef.current && related && containerRef.current.contains(related))
1033
+ return;
1034
+ setDropTargetPath(null);
1035
+ }, []);
1036
+ const handleDrop = useCallback((path, e) => {
1037
+ e.preventDefault();
1038
+ e.stopPropagation();
1039
+ if (!draggedPaths || draggedPaths.size === 0) {
1040
+ setDraggedPaths(null);
1041
+ setDropTargetPath(null);
1042
+ return;
1043
+ }
1044
+ const found = findNodeByPath(files, path);
1045
+ const targetDir = found?.node.type === 'directory' ? path : getParentDir(path);
1046
+ const moves = [];
1047
+ for (const oldPath of draggedPaths) {
1048
+ const fileName = oldPath.split('/').pop();
1049
+ const newPath = `${targetDir}/${fileName}`;
1050
+ if (newPath !== oldPath) {
1051
+ moves.push({ oldPath, newPath });
1052
+ }
1053
+ }
1054
+ if (moves.length > 0)
1055
+ onMoveFiles?.(moves);
1056
+ setDraggedPaths(null);
1057
+ setDropTargetPath(null);
1058
+ setSelectedPaths(new Set());
1059
+ }, [draggedPaths, files, onMoveFiles]);
1060
+ const handleBackgroundDragOver = useCallback((e) => {
1061
+ if (!draggedPaths)
1062
+ return;
1063
+ e.preventDefault();
1064
+ e.dataTransfer.dropEffect = 'move';
1065
+ setDropTargetPath('/workspace');
1066
+ }, [draggedPaths]);
1067
+ const handleBackgroundDrop = useCallback((e) => {
1068
+ e.preventDefault();
1069
+ if (!draggedPaths || draggedPaths.size === 0) {
1070
+ setDraggedPaths(null);
1071
+ setDropTargetPath(null);
1072
+ return;
1073
+ }
1074
+ const moves = [];
1075
+ for (const oldPath of draggedPaths) {
1076
+ const fileName = oldPath.split('/').pop();
1077
+ const newPath = `/workspace/${fileName}`;
1078
+ if (newPath !== oldPath) {
1079
+ moves.push({ oldPath, newPath });
1080
+ }
1081
+ }
1082
+ if (moves.length > 0)
1083
+ onMoveFiles?.(moves);
1084
+ setDraggedPaths(null);
1085
+ setDropTargetPath(null);
1086
+ setSelectedPaths(new Set());
1087
+ }, [draggedPaths, onMoveFiles]);
1088
+ const handleDragEnd = useCallback(() => {
1089
+ setDraggedPaths(null);
1090
+ setDropTargetPath(null);
1091
+ }, []);
1092
+ // ---------------------------------------------------------------------------
1093
+ // Render
1094
+ // ---------------------------------------------------------------------------
1095
+ // Sort root-level entries: directories first, then files, alphabetically
1096
+ const sortedFiles = sortNodes(files);
1097
+ // Shared mutable counter — reset each render, incremented by each FileTreeItem.
1098
+ // Prevents symlink cycles from freezing the browser by capping total rendered nodes.
1099
+ const renderCountRef = useRef({ current: 0 });
1100
+ renderCountRef.current.current = 0;
1101
+ return (_jsxs("div", { ref: containerRef, className: cm.cn(cm.sp('py', 1), cm.sp('pl', 1), className), style: { overflowY: 'auto', outline: 'none' }, role: "tree", tabIndex: 0, onKeyDown: handleTreeKeyDown, onContextMenu: handleBackgroundContextMenu, onPointerDown: (e) => {
1102
+ // Row presses bubble here too — those belong to the row's own
1103
+ // long-press recognizer, so only arm the background one for presses
1104
+ // that start on the empty tree area.
1105
+ if (e.target.closest('[data-explorer-path]'))
1106
+ return;
1107
+ backgroundLongPress.onPointerDown(e);
1108
+ }, onPointerMove: backgroundLongPress.onPointerMove, onPointerUp: backgroundLongPress.onPointerUp, onPointerCancel: backgroundLongPress.onPointerCancel, onTouchEnd: backgroundLongPress.onTouchEnd, onDragOver: handleBackgroundDragOver, onDrop: handleBackgroundDrop, onDragEnd: handleDragEnd, children: [files.length === 0 && (_jsx("div", { className: cm.cn(cm.textMuted, cm.textSize('sm'), cm.sp('p', 3)), children: t('ide.files.empty') })), sortedFiles.map((node) => (_jsx(FileTreeItem, { node: node, depth: 0, isExpanded: isExpandedFromState(expandState, node.path, 0), activeFile: activeFile, isSelected: selectedPaths.has(node.path), isCut: clipboard?.operation === 'cut' && clipboard.paths.has(node.path), isFocused: focusedPath === node.path, isDropTarget: dropTargetPath === node.path, onFileSelect: onFileSelect, onFileDoubleClick: onFileDoubleClick, onDirExpand: onDirExpand, onTogglePath: handleTogglePath, onSymlinkClick: handleSymlinkClick, onContextMenu: handleItemContextMenu, onItemClick: handleItemClick, onDragStart: handleDragStart, onDragOver: handleDragOver, onDragLeave: handleItemDragLeave, onDrop: handleDrop, expandState: expandState, fileStatuses: fileStatuses, gitColors: gitColors, selectedPaths: selectedPaths, clipboardPaths: clipboard?.paths ?? null, clipboardOperation: clipboard?.operation ?? null, renderCount: renderCountRef.current, dropTargetPath: dropTargetPath, isCoarse: isCoarse }, node.path))), contextMenu && (_jsx(FileExplorerContextMenu, { position: contextMenu.position, node: contextMenu.node, selectedCount: selectedPaths.size, canPaste: clipboard?.operation === 'cut' && clipboard.paths.size > 0, onAction: handleContextMenuAction, onClose: handleCloseContextMenu }))] }));
1109
+ }
1110
+ FileExplorer.displayName = 'FileExplorer';
1111
+ //# sourceMappingURL=FileExplorer.js.map