@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.
- package/LICENSE +115 -0
- package/dist/chatTimelineOrdering.d.ts +50 -0
- package/dist/chatTimelineOrdering.d.ts.map +1 -0
- package/dist/chatTimelineOrdering.js +46 -0
- package/dist/chatTimelineOrdering.js.map +1 -0
- package/dist/command-metadata.d.ts +78 -0
- package/dist/command-metadata.d.ts.map +1 -0
- package/dist/command-metadata.js +214 -0
- package/dist/command-metadata.js.map +1 -0
- package/dist/components/ActivityCard.d.ts +28 -0
- package/dist/components/ActivityCard.d.ts.map +1 -0
- package/dist/components/ActivityCard.js +62 -0
- package/dist/components/ActivityCard.js.map +1 -0
- package/dist/components/AutoCommitBadge.d.ts +58 -0
- package/dist/components/AutoCommitBadge.d.ts.map +1 -0
- package/dist/components/AutoCommitBadge.js +78 -0
- package/dist/components/AutoCommitBadge.js.map +1 -0
- package/dist/components/ChatPanel.d.ts +184 -0
- package/dist/components/ChatPanel.d.ts.map +1 -0
- package/dist/components/ChatPanel.js +6691 -0
- package/dist/components/ChatPanel.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +18 -0
- package/dist/components/CommandPalette.d.ts.map +1 -0
- package/dist/components/CommandPalette.js +33 -0
- package/dist/components/CommandPalette.js.map +1 -0
- package/dist/components/DeviceFrameSelector.d.ts +60 -0
- package/dist/components/DeviceFrameSelector.d.ts.map +1 -0
- package/dist/components/DeviceFrameSelector.js +230 -0
- package/dist/components/DeviceFrameSelector.js.map +1 -0
- package/dist/components/EditorPanel.d.ts +17 -0
- package/dist/components/EditorPanel.d.ts.map +1 -0
- package/dist/components/EditorPanel.js +241 -0
- package/dist/components/EditorPanel.js.map +1 -0
- package/dist/components/FileExplorer.d.ts +64 -0
- package/dist/components/FileExplorer.d.ts.map +1 -0
- package/dist/components/FileExplorer.js +1111 -0
- package/dist/components/FileExplorer.js.map +1 -0
- package/dist/components/FileExplorerContextMenu.d.ts +37 -0
- package/dist/components/FileExplorerContextMenu.d.ts.map +1 -0
- package/dist/components/FileExplorerContextMenu.js +241 -0
- package/dist/components/FileExplorerContextMenu.js.map +1 -0
- package/dist/components/HelpCard.d.ts +34 -0
- package/dist/components/HelpCard.d.ts.map +1 -0
- package/dist/components/HelpCard.js +28 -0
- package/dist/components/HelpCard.js.map +1 -0
- package/dist/components/Icon.d.ts +50 -0
- package/dist/components/Icon.d.ts.map +1 -0
- package/dist/components/Icon.js +25 -0
- package/dist/components/Icon.js.map +1 -0
- package/dist/components/KeyboardShortcutsPanel.d.ts +19 -0
- package/dist/components/KeyboardShortcutsPanel.d.ts.map +1 -0
- package/dist/components/KeyboardShortcutsPanel.js +187 -0
- package/dist/components/KeyboardShortcutsPanel.js.map +1 -0
- package/dist/components/MarkdownContent.d.ts +52 -0
- package/dist/components/MarkdownContent.d.ts.map +1 -0
- package/dist/components/MarkdownContent.js +303 -0
- package/dist/components/MarkdownContent.js.map +1 -0
- package/dist/components/ModelsTable.d.ts +38 -0
- package/dist/components/ModelsTable.d.ts.map +1 -0
- package/dist/components/ModelsTable.js +96 -0
- package/dist/components/ModelsTable.js.map +1 -0
- package/dist/components/PreviewPanel.d.ts +66 -0
- package/dist/components/PreviewPanel.d.ts.map +1 -0
- package/dist/components/PreviewPanel.js +1752 -0
- package/dist/components/PreviewPanel.js.map +1 -0
- package/dist/components/QuickOpen.d.ts +20 -0
- package/dist/components/QuickOpen.d.ts.map +1 -0
- package/dist/components/QuickOpen.js +72 -0
- package/dist/components/QuickOpen.js.map +1 -0
- package/dist/components/QuickPicker.d.ts +21 -0
- package/dist/components/QuickPicker.d.ts.map +1 -0
- package/dist/components/QuickPicker.js +174 -0
- package/dist/components/QuickPicker.js.map +1 -0
- package/dist/components/RelevantSkillSuggestion.d.ts +41 -0
- package/dist/components/RelevantSkillSuggestion.d.ts.map +1 -0
- package/dist/components/RelevantSkillSuggestion.js +85 -0
- package/dist/components/RelevantSkillSuggestion.js.map +1 -0
- package/dist/components/ReportModal.d.ts +35 -0
- package/dist/components/ReportModal.d.ts.map +1 -0
- package/dist/components/ReportModal.js +112 -0
- package/dist/components/ReportModal.js.map +1 -0
- package/dist/components/ResizeHandle.d.ts +21 -0
- package/dist/components/ResizeHandle.d.ts.map +1 -0
- package/dist/components/ResizeHandle.js +109 -0
- package/dist/components/ResizeHandle.js.map +1 -0
- package/dist/components/ScriptsCard.d.ts +37 -0
- package/dist/components/ScriptsCard.d.ts.map +1 -0
- package/dist/components/ScriptsCard.js +177 -0
- package/dist/components/ScriptsCard.js.map +1 -0
- package/dist/components/SearchPanel.d.ts +18 -0
- package/dist/components/SearchPanel.d.ts.map +1 -0
- package/dist/components/SearchPanel.js +449 -0
- package/dist/components/SearchPanel.js.map +1 -0
- package/dist/components/SettingsCard.d.ts +49 -0
- package/dist/components/SettingsCard.d.ts.map +1 -0
- package/dist/components/SettingsCard.js +59 -0
- package/dist/components/SettingsCard.js.map +1 -0
- package/dist/components/ShareModal.d.ts +32 -0
- package/dist/components/ShareModal.d.ts.map +1 -0
- package/dist/components/ShareModal.js +126 -0
- package/dist/components/ShareModal.js.map +1 -0
- package/dist/components/SidebarTabs.d.ts +18 -0
- package/dist/components/SidebarTabs.d.ts.map +1 -0
- package/dist/components/SidebarTabs.js +20 -0
- package/dist/components/SidebarTabs.js.map +1 -0
- package/dist/components/SkillsCard.d.ts +86 -0
- package/dist/components/SkillsCard.d.ts.map +1 -0
- package/dist/components/SkillsCard.js +227 -0
- package/dist/components/SkillsCard.js.map +1 -0
- package/dist/components/StreamingIndicator.d.ts +50 -0
- package/dist/components/StreamingIndicator.d.ts.map +1 -0
- package/dist/components/StreamingIndicator.js +142 -0
- package/dist/components/StreamingIndicator.js.map +1 -0
- package/dist/components/TabBar.d.ts +37 -0
- package/dist/components/TabBar.d.ts.map +1 -0
- package/dist/components/TabBar.js +187 -0
- package/dist/components/TabBar.js.map +1 -0
- package/dist/components/TipCard.d.ts +22 -0
- package/dist/components/TipCard.d.ts.map +1 -0
- package/dist/components/TipCard.js +73 -0
- package/dist/components/TipCard.js.map +1 -0
- package/dist/components/ToolCallCard.d.ts +18 -0
- package/dist/components/ToolCallCard.d.ts.map +1 -0
- package/dist/components/ToolCallCard.js +716 -0
- package/dist/components/ToolCallCard.js.map +1 -0
- package/dist/components/UserAvatar.d.ts +45 -0
- package/dist/components/UserAvatar.d.ts.map +1 -0
- package/dist/components/UserAvatar.js +80 -0
- package/dist/components/UserAvatar.js.map +1 -0
- package/dist/components/WorkspaceLayout.d.ts +19 -0
- package/dist/components/WorkspaceLayout.d.ts.map +1 -0
- package/dist/components/WorkspaceLayout.js +56 -0
- package/dist/components/WorkspaceLayout.js.map +1 -0
- package/dist/components/activity-utilities.d.ts +107 -0
- package/dist/components/activity-utilities.d.ts.map +1 -0
- package/dist/components/activity-utilities.js +154 -0
- package/dist/components/activity-utilities.js.map +1 -0
- package/dist/components/chat-autocommit-utilities.d.ts +172 -0
- package/dist/components/chat-autocommit-utilities.d.ts.map +1 -0
- package/dist/components/chat-autocommit-utilities.js +185 -0
- package/dist/components/chat-autocommit-utilities.js.map +1 -0
- package/dist/components/chat-card-style.d.ts +64 -0
- package/dist/components/chat-card-style.d.ts.map +1 -0
- package/dist/components/chat-card-style.js +74 -0
- package/dist/components/chat-card-style.js.map +1 -0
- package/dist/components/chat-commands.d.ts +17 -0
- package/dist/components/chat-commands.d.ts.map +1 -0
- package/dist/components/chat-commands.js +17 -0
- package/dist/components/chat-commands.js.map +1 -0
- package/dist/components/chat-commit-utilities.d.ts +29 -0
- package/dist/components/chat-commit-utilities.d.ts.map +1 -0
- package/dist/components/chat-commit-utilities.js +31 -0
- package/dist/components/chat-commit-utilities.js.map +1 -0
- package/dist/components/chat-cost-utilities.d.ts +62 -0
- package/dist/components/chat-cost-utilities.d.ts.map +1 -0
- package/dist/components/chat-cost-utilities.js +66 -0
- package/dist/components/chat-cost-utilities.js.map +1 -0
- package/dist/components/chat-effort-utilities.d.ts +96 -0
- package/dist/components/chat-effort-utilities.d.ts.map +1 -0
- package/dist/components/chat-effort-utilities.js +97 -0
- package/dist/components/chat-effort-utilities.js.map +1 -0
- package/dist/components/chat-help-utilities.d.ts +89 -0
- package/dist/components/chat-help-utilities.d.ts.map +1 -0
- package/dist/components/chat-help-utilities.js +131 -0
- package/dist/components/chat-help-utilities.js.map +1 -0
- package/dist/components/chat-model-mode-utilities.d.ts +143 -0
- package/dist/components/chat-model-mode-utilities.d.ts.map +1 -0
- package/dist/components/chat-model-mode-utilities.js +191 -0
- package/dist/components/chat-model-mode-utilities.js.map +1 -0
- package/dist/components/chat-models-utilities.d.ts +98 -0
- package/dist/components/chat-models-utilities.d.ts.map +1 -0
- package/dist/components/chat-models-utilities.js +136 -0
- package/dist/components/chat-models-utilities.js.map +1 -0
- package/dist/components/chat-report-utilities.d.ts +149 -0
- package/dist/components/chat-report-utilities.d.ts.map +1 -0
- package/dist/components/chat-report-utilities.js +151 -0
- package/dist/components/chat-report-utilities.js.map +1 -0
- package/dist/components/chat-scripts-utilities.d.ts +140 -0
- package/dist/components/chat-scripts-utilities.d.ts.map +1 -0
- package/dist/components/chat-scripts-utilities.js +158 -0
- package/dist/components/chat-scripts-utilities.js.map +1 -0
- package/dist/components/chat-settings-utilities.d.ts +72 -0
- package/dist/components/chat-settings-utilities.d.ts.map +1 -0
- package/dist/components/chat-settings-utilities.js +56 -0
- package/dist/components/chat-settings-utilities.js.map +1 -0
- package/dist/components/chat-share-utilities.d.ts +104 -0
- package/dist/components/chat-share-utilities.d.ts.map +1 -0
- package/dist/components/chat-share-utilities.js +90 -0
- package/dist/components/chat-share-utilities.js.map +1 -0
- package/dist/components/chat-skills-utilities.d.ts +242 -0
- package/dist/components/chat-skills-utilities.d.ts.map +1 -0
- package/dist/components/chat-skills-utilities.js +433 -0
- package/dist/components/chat-skills-utilities.js.map +1 -0
- package/dist/components/chat-stream-utilities.d.ts +74 -0
- package/dist/components/chat-stream-utilities.d.ts.map +1 -0
- package/dist/components/chat-stream-utilities.js +103 -0
- package/dist/components/chat-stream-utilities.js.map +1 -0
- package/dist/components/chat-tips-utilities.d.ts +108 -0
- package/dist/components/chat-tips-utilities.d.ts.map +1 -0
- package/dist/components/chat-tips-utilities.js +137 -0
- package/dist/components/chat-tips-utilities.js.map +1 -0
- package/dist/components/device-cycle.d.ts +84 -0
- package/dist/components/device-cycle.d.ts.map +1 -0
- package/dist/components/device-cycle.js +77 -0
- package/dist/components/device-cycle.js.map +1 -0
- package/dist/components/index.d.ts +36 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +33 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/tool-call-utilities.d.ts +69 -0
- package/dist/components/tool-call-utilities.d.ts.map +1 -0
- package/dist/components/tool-call-utilities.js +368 -0
- package/dist/components/tool-call-utilities.js.map +1 -0
- package/dist/components/user-avatar-utilities.d.ts +41 -0
- package/dist/components/user-avatar-utilities.d.ts.map +1 -0
- package/dist/components/user-avatar-utilities.js +56 -0
- package/dist/components/user-avatar-utilities.js.map +1 -0
- package/dist/components/workspace-layout-utilities.d.ts +37 -0
- package/dist/components/workspace-layout-utilities.d.ts.map +1 -0
- package/dist/components/workspace-layout-utilities.js +58 -0
- package/dist/components/workspace-layout-utilities.js.map +1 -0
- package/dist/customEventCards.d.ts +117 -0
- package/dist/customEventCards.d.ts.map +1 -0
- package/dist/customEventCards.js +36 -0
- package/dist/customEventCards.js.map +1 -0
- package/dist/hooks/index.d.ts +8 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +8 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts +48 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts.map +1 -0
- package/dist/hooks/useKeyboardShortcuts.js +112 -0
- package/dist/hooks/useKeyboardShortcuts.js.map +1 -0
- package/dist/hooks/useViewport.d.ts +42 -0
- package/dist/hooks/useViewport.d.ts.map +1 -0
- package/dist/hooks/useViewport.js +61 -0
- package/dist/hooks/useViewport.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +74 -0
- package/dist/index.js.map +1 -0
- package/dist/settings-metadata.d.ts +78 -0
- package/dist/settings-metadata.d.ts.map +1 -0
- package/dist/settings-metadata.js +131 -0
- package/dist/settings-metadata.js.map +1 -0
- package/dist/types.d.ts +751 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +25 -0
- package/dist/types.js.map +1 -0
- 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
|