@hienlh/ppm 0.13.15 → 0.13.16
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/CHANGELOG.md +22 -0
- package/CLAUDE.md +5 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +2135 -0
- package/bunfig.toml +2 -0
- package/dist/web/assets/{audio-preview-YOG6Biao.js → audio-preview-bQ4k3Rdv.js} +1 -1
- package/dist/web/assets/{chat-tab-DbdDJuLu.js → chat-tab-DISlwA7-.js} +3 -3
- package/dist/web/assets/code-editor-Cni2pSOw.js +8 -0
- package/dist/web/assets/{conflict-editor-DnGfriL5.js → conflict-editor-82mk659D.js} +1 -1
- package/dist/web/assets/{database-viewer-AodppoTs.js → database-viewer-eCnvGdDi.js} +1 -1
- package/dist/web/assets/{diff-viewer-DykLUwna.js → diff-viewer-cezBVQp6.js} +1 -1
- package/dist/web/assets/{extension-webview-Bck7QuaB.js → extension-webview-B5dN_Qrm.js} +1 -1
- package/dist/web/assets/file-store-DOxcU_7s.js +1 -0
- package/dist/web/assets/{glide-data-grid-BVt0mwcA.js → glide-data-grid-yscGXxJe.js} +1 -1
- package/dist/web/assets/{image-preview-DaSmrIvY.js → image-preview-CGdBnOP0.js} +1 -1
- package/dist/web/assets/index-C_pdjLi6.js +27 -0
- package/dist/web/assets/index-nC9UURj4.css +2 -0
- package/dist/web/assets/keybindings-store-LHrHsvXn.js +1 -0
- package/dist/web/assets/{markdown-renderer-B1me_hz2.js → markdown-renderer-DF-Ga1mN.js} +1 -1
- package/dist/web/assets/{pdf-preview-Dci7TIL1.js → pdf-preview-C15gYiMf.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BeM40G-J.js → port-forwarding-tab-BcpVh4oH.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CGVBOwA9.js → postgres-viewer-DqcY70o6.js} +1 -1
- package/dist/web/assets/{settings-tab-CYS8VfNl.js → settings-tab-CMso6o_A.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DstPySPF.js → sql-query-editor-C0Lq3NYC.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-SUGEk_G1.js → sqlite-viewer-CFHqKvjt.js} +1 -1
- package/dist/web/assets/{terminal-tab-CJvjF79J.js → terminal-tab-ej7HGI3k.js} +1 -1
- package/dist/web/assets/{video-preview-gJSKmPQr.js → video-preview-BZLGMaKk.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/index.ts +0 -0
- package/src/server/routes/files.ts +15 -0
- package/src/services/file.service.ts +15 -0
- package/src/web/components/editor/editor-breadcrumb.tsx +88 -36
- package/src/web/components/explorer/file-actions.tsx +12 -129
- package/src/web/components/explorer/file-icon-map.ts +69 -0
- package/src/web/components/explorer/file-tree.tsx +177 -362
- package/src/web/components/explorer/inline-tree-input.tsx +120 -0
- package/src/web/components/explorer/tree-node-context-menu.tsx +97 -0
- package/src/web/components/explorer/tree-node.tsx +343 -0
- package/src/web/components/explorer/use-file-upload-drag.ts +77 -0
- package/src/web/components/explorer/use-tree-keyboard-nav.ts +126 -0
- package/src/web/components/layout/mobile-nav.tsx +73 -84
- package/src/web/components/layout/project-bottom-sheet.tsx +61 -82
- package/src/web/components/ui/adaptive-context-menu.tsx +245 -0
- package/src/web/components/ui/mobile-bottom-sheet.tsx +155 -0
- package/src/web/hooks/use-is-mobile.ts +28 -0
- package/src/web/hooks/use-swipe-to-dismiss.ts +46 -0
- package/src/web/stores/file-store.ts +74 -3
- package/src/web/stores/git-status-store.ts +87 -2
- package/dist/web/assets/code-editor-C4nuAsy6.js +0 -8
- package/dist/web/assets/file-store-4BpOJthN.js +0 -1
- package/dist/web/assets/index-CSK33ACc.css +0 -2
- package/dist/web/assets/index-gZKF1YKy.js +0 -27
- package/dist/web/assets/keybindings-store-DBKLTPrk.js +0 -1
|
@@ -1,25 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* FileTree — the main file explorer container.
|
|
3
|
+
* Renders toolbar, tree nodes via TreeNode, root-level drag/drop, and file actions.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useCallback, useState, useRef } from "react";
|
|
2
6
|
import {
|
|
3
|
-
Folder,
|
|
4
|
-
FolderOpen,
|
|
5
|
-
File,
|
|
6
|
-
FileCode,
|
|
7
|
-
FileJson,
|
|
8
|
-
FileText,
|
|
9
|
-
FileType,
|
|
10
|
-
FileImage,
|
|
11
|
-
FileVideo,
|
|
12
|
-
FileAudio,
|
|
13
|
-
FileSpreadsheet,
|
|
14
|
-
FileArchive,
|
|
15
|
-
Database,
|
|
16
|
-
ChevronRight,
|
|
17
|
-
ChevronDown,
|
|
18
|
-
Download,
|
|
19
|
-
Loader2,
|
|
20
7
|
FilePlus,
|
|
21
8
|
FolderPlus,
|
|
22
9
|
RefreshCw,
|
|
10
|
+
ChevronsDownUp,
|
|
11
|
+
Crosshair,
|
|
23
12
|
} from "lucide-react";
|
|
24
13
|
import { useShallow } from "zustand/react/shallow";
|
|
25
14
|
import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
@@ -36,283 +25,19 @@ import {
|
|
|
36
25
|
ContextMenuItem,
|
|
37
26
|
ContextMenuSeparator,
|
|
38
27
|
ContextMenuTrigger,
|
|
39
|
-
} from "@/components/ui/context-menu";
|
|
28
|
+
} from "@/components/ui/adaptive-context-menu";
|
|
40
29
|
import { FileActions } from "./file-actions";
|
|
30
|
+
import { TreeNode } from "./tree-node";
|
|
31
|
+
import { InlineTreeInput } from "./inline-tree-input";
|
|
41
32
|
import { downloadFile, downloadFolder } from "@/lib/file-download";
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
|
|
47
|
-
}
|
|
33
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
34
|
+
import { useFileUploadDrag } from "./use-file-upload-drag";
|
|
35
|
+
import { Loader2 } from "lucide-react";
|
|
36
|
+
import { useTreeKeyboardNav } from "./use-tree-keyboard-nav";
|
|
48
37
|
|
|
49
38
|
/** Synthetic root node for creating files/folders at project root */
|
|
50
39
|
const ROOT_NODE: FileNode = { name: "", path: "", type: "directory" };
|
|
51
40
|
|
|
52
|
-
type FileIconInfo = { icon: React.ComponentType<{ className?: string }>; color?: string };
|
|
53
|
-
|
|
54
|
-
const FILE_ICON_MAP: Record<string, FileIconInfo> = {
|
|
55
|
-
// Code
|
|
56
|
-
ts: { icon: FileCode, color: "text-blue-400" }, tsx: { icon: FileCode, color: "text-blue-400" },
|
|
57
|
-
js: { icon: FileCode, color: "text-yellow-400" }, jsx: { icon: FileCode, color: "text-yellow-400" },
|
|
58
|
-
py: { icon: FileCode, color: "text-green-400" }, rs: { icon: FileCode, color: "text-orange-400" },
|
|
59
|
-
go: { icon: FileCode, color: "text-cyan-400" }, c: { icon: FileCode, color: "text-blue-300" },
|
|
60
|
-
cpp: { icon: FileCode, color: "text-blue-300" }, java: { icon: FileCode, color: "text-red-400" },
|
|
61
|
-
rb: { icon: FileCode, color: "text-red-400" }, php: { icon: FileCode, color: "text-purple-400" },
|
|
62
|
-
swift: { icon: FileCode, color: "text-orange-400" }, kt: { icon: FileCode, color: "text-purple-400" },
|
|
63
|
-
dart: { icon: FileCode, color: "text-cyan-400" }, sh: { icon: FileCode, color: "text-green-300" },
|
|
64
|
-
html: { icon: FileCode, color: "text-orange-400" }, css: { icon: FileCode, color: "text-blue-400" },
|
|
65
|
-
scss: { icon: FileCode, color: "text-pink-400" },
|
|
66
|
-
// Data
|
|
67
|
-
json: { icon: FileJson, color: "text-yellow-400" },
|
|
68
|
-
yaml: { icon: FileType, color: "text-orange-300" }, yml: { icon: FileType, color: "text-orange-300" },
|
|
69
|
-
toml: { icon: FileType, color: "text-orange-300" }, ini: { icon: FileType, color: "text-orange-300" },
|
|
70
|
-
env: { icon: FileType, color: "text-yellow-300" },
|
|
71
|
-
csv: { icon: FileSpreadsheet, color: "text-green-400" },
|
|
72
|
-
xls: { icon: FileSpreadsheet, color: "text-green-400" }, xlsx: { icon: FileSpreadsheet, color: "text-green-400" },
|
|
73
|
-
// Text/Docs
|
|
74
|
-
md: { icon: FileText, color: "text-text-secondary" }, txt: { icon: FileText, color: "text-text-secondary" },
|
|
75
|
-
log: { icon: FileText, color: "text-text-subtle" }, pdf: { icon: FileText, color: "text-red-400" },
|
|
76
|
-
// Images
|
|
77
|
-
png: { icon: FileImage, color: "text-green-400" }, jpg: { icon: FileImage, color: "text-green-400" },
|
|
78
|
-
jpeg: { icon: FileImage, color: "text-green-400" }, gif: { icon: FileImage, color: "text-green-400" },
|
|
79
|
-
svg: { icon: FileImage, color: "text-yellow-400" }, webp: { icon: FileImage, color: "text-green-400" },
|
|
80
|
-
ico: { icon: FileImage, color: "text-green-400" }, bmp: { icon: FileImage, color: "text-green-400" },
|
|
81
|
-
// Video
|
|
82
|
-
mp4: { icon: FileVideo, color: "text-purple-400" }, webm: { icon: FileVideo, color: "text-purple-400" },
|
|
83
|
-
mov: { icon: FileVideo, color: "text-purple-400" }, avi: { icon: FileVideo, color: "text-purple-400" },
|
|
84
|
-
mkv: { icon: FileVideo, color: "text-purple-400" },
|
|
85
|
-
// Audio
|
|
86
|
-
mp3: { icon: FileAudio, color: "text-pink-400" }, wav: { icon: FileAudio, color: "text-pink-400" },
|
|
87
|
-
ogg: { icon: FileAudio, color: "text-pink-400" }, flac: { icon: FileAudio, color: "text-pink-400" },
|
|
88
|
-
// Database
|
|
89
|
-
db: { icon: Database, color: "text-amber-400" }, sqlite: { icon: Database, color: "text-amber-400" },
|
|
90
|
-
sqlite3: { icon: Database, color: "text-amber-400" }, sql: { icon: Database, color: "text-amber-400" },
|
|
91
|
-
// Archives
|
|
92
|
-
zip: { icon: FileArchive, color: "text-amber-300" }, tar: { icon: FileArchive, color: "text-amber-300" },
|
|
93
|
-
gz: { icon: FileArchive, color: "text-amber-300" }, rar: { icon: FileArchive, color: "text-amber-300" },
|
|
94
|
-
"7z": { icon: FileArchive, color: "text-amber-300" },
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const DEFAULT_FILE_ICON: FileIconInfo = { icon: File };
|
|
98
|
-
|
|
99
|
-
function getFileIcon(name: string): FileIconInfo {
|
|
100
|
-
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
101
|
-
return FILE_ICON_MAP[ext] ?? DEFAULT_FILE_ICON;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface TreeNodeProps {
|
|
105
|
-
node: FileNode;
|
|
106
|
-
depth: number;
|
|
107
|
-
projectName: string;
|
|
108
|
-
onAction: (action: string, node: FileNode) => void;
|
|
109
|
-
onFileDrop: (targetDir: string, files: FileList) => void;
|
|
110
|
-
onFileOpen?: () => void;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
|
|
114
|
-
const { expandedPaths, loadedPaths, inflight, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore(
|
|
115
|
-
useShallow((s) => ({
|
|
116
|
-
expandedPaths: s.expandedPaths,
|
|
117
|
-
loadedPaths: s.loadedPaths,
|
|
118
|
-
inflight: s.inflight,
|
|
119
|
-
toggleExpand: s.toggleExpand,
|
|
120
|
-
selectedFiles: s.selectedFiles,
|
|
121
|
-
toggleFileSelect: s.toggleFileSelect,
|
|
122
|
-
})),
|
|
123
|
-
);
|
|
124
|
-
const openTab = useTabStore((s) => s.openTab);
|
|
125
|
-
const compareSelection = useCompareStore((s) => s.selection);
|
|
126
|
-
const isExpanded = expandedPaths.has(node.path);
|
|
127
|
-
const isDir = node.type === "directory";
|
|
128
|
-
const isSelected = selectedFiles.includes(node.path);
|
|
129
|
-
const isIgnored = node.ignored === true;
|
|
130
|
-
const isLoadingChildren = isDir && isExpanded && !loadedPaths.has(node.path) && inflight.has(node.path);
|
|
131
|
-
const [isDragOver, setIsDragOver] = useState(false);
|
|
132
|
-
const dragCounter = useRef(0);
|
|
133
|
-
|
|
134
|
-
function handleClick(e: React.MouseEvent) {
|
|
135
|
-
if (isDir) {
|
|
136
|
-
toggleExpand(projectName, node.path);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
// Ctrl/Cmd+Click: toggle file selection for compare
|
|
140
|
-
if (e.metaKey || e.ctrlKey) {
|
|
141
|
-
toggleFileSelect(node.path);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
|
|
145
|
-
const isSqlite = ext === "db" || ext === "sqlite" || ext === "sqlite3";
|
|
146
|
-
openTab({
|
|
147
|
-
type: isSqlite ? "sqlite" : "editor",
|
|
148
|
-
title: node.name,
|
|
149
|
-
metadata: { filePath: node.path, projectName },
|
|
150
|
-
projectId: projectName,
|
|
151
|
-
closable: true,
|
|
152
|
-
});
|
|
153
|
-
onFileOpen?.();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function handleDragStart(e: React.DragEvent) {
|
|
157
|
-
const pathValue = isDir ? `${node.path}/` : node.path;
|
|
158
|
-
e.dataTransfer.setData("application/x-ppm-path", pathValue);
|
|
159
|
-
e.dataTransfer.setData("text/plain", node.name);
|
|
160
|
-
e.dataTransfer.effectAllowed = "copy";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function handleNodeDragEnter(e: React.DragEvent) {
|
|
164
|
-
if (!isDir || !isExternalFileDrag(e)) return;
|
|
165
|
-
e.preventDefault();
|
|
166
|
-
e.stopPropagation();
|
|
167
|
-
dragCounter.current++;
|
|
168
|
-
if (dragCounter.current === 1) setIsDragOver(true);
|
|
169
|
-
}
|
|
170
|
-
function handleNodeDragLeave(e: React.DragEvent) {
|
|
171
|
-
if (!isDir) return;
|
|
172
|
-
e.stopPropagation();
|
|
173
|
-
dragCounter.current--;
|
|
174
|
-
if (dragCounter.current === 0) setIsDragOver(false);
|
|
175
|
-
}
|
|
176
|
-
function handleNodeDragOver(e: React.DragEvent) {
|
|
177
|
-
if (!isDir || !isExternalFileDrag(e)) return;
|
|
178
|
-
e.preventDefault();
|
|
179
|
-
e.stopPropagation();
|
|
180
|
-
e.dataTransfer.dropEffect = "copy";
|
|
181
|
-
}
|
|
182
|
-
function handleNodeDrop(e: React.DragEvent) {
|
|
183
|
-
if (!isDir || !isExternalFileDrag(e)) return;
|
|
184
|
-
e.preventDefault();
|
|
185
|
-
e.stopPropagation();
|
|
186
|
-
dragCounter.current = 0;
|
|
187
|
-
setIsDragOver(false);
|
|
188
|
-
if (e.dataTransfer.files.length > 0) {
|
|
189
|
-
onFileDrop(node.path, e.dataTransfer.files);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const { icon: FileIcon, color: fileIconColor } = isDir
|
|
194
|
-
? { icon: isExpanded ? FolderOpen : Folder, color: "text-primary" }
|
|
195
|
-
: getFileIcon(node.name);
|
|
196
|
-
|
|
197
|
-
const sortedChildren = node.children
|
|
198
|
-
? [...node.children].sort((a, b) => {
|
|
199
|
-
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
200
|
-
return a.name.localeCompare(b.name);
|
|
201
|
-
})
|
|
202
|
-
: [];
|
|
203
|
-
|
|
204
|
-
return (
|
|
205
|
-
<div
|
|
206
|
-
onDragEnter={isDir ? handleNodeDragEnter : undefined}
|
|
207
|
-
onDragLeave={isDir ? handleNodeDragLeave : undefined}
|
|
208
|
-
onDragOver={isDir ? handleNodeDragOver : undefined}
|
|
209
|
-
onDrop={isDir ? handleNodeDrop : undefined}
|
|
210
|
-
>
|
|
211
|
-
<ContextMenu>
|
|
212
|
-
<ContextMenuTrigger asChild>
|
|
213
|
-
<button
|
|
214
|
-
draggable
|
|
215
|
-
onDragStart={handleDragStart}
|
|
216
|
-
onClick={handleClick}
|
|
217
|
-
className={cn(
|
|
218
|
-
"flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
|
|
219
|
-
"min-h-[32px] hover:bg-surface-elevated transition-colors text-left",
|
|
220
|
-
"select-none",
|
|
221
|
-
isIgnored && "opacity-40",
|
|
222
|
-
isSelected && "bg-primary/15 ring-1 ring-primary/40",
|
|
223
|
-
isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
|
|
224
|
-
)}
|
|
225
|
-
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
226
|
-
>
|
|
227
|
-
{isDir ? (
|
|
228
|
-
isLoadingChildren ? (
|
|
229
|
-
<Loader2 className="size-3.5 shrink-0 text-text-subtle animate-spin" />
|
|
230
|
-
) : isExpanded ? (
|
|
231
|
-
<ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
|
|
232
|
-
) : (
|
|
233
|
-
<ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
|
|
234
|
-
)
|
|
235
|
-
) : (
|
|
236
|
-
<span className="w-3.5 shrink-0" />
|
|
237
|
-
)}
|
|
238
|
-
<FileIcon
|
|
239
|
-
className={cn(
|
|
240
|
-
"size-4 shrink-0",
|
|
241
|
-
fileIconColor ?? "text-text-secondary",
|
|
242
|
-
)}
|
|
243
|
-
/>
|
|
244
|
-
<span className="truncate">{node.name}</span>
|
|
245
|
-
</button>
|
|
246
|
-
</ContextMenuTrigger>
|
|
247
|
-
<ContextMenuContent>
|
|
248
|
-
{isDir && (
|
|
249
|
-
<>
|
|
250
|
-
<ContextMenuItem onClick={() => onAction("new-file", node)}>
|
|
251
|
-
New File
|
|
252
|
-
</ContextMenuItem>
|
|
253
|
-
<ContextMenuItem onClick={() => onAction("new-folder", node)}>
|
|
254
|
-
New Folder
|
|
255
|
-
</ContextMenuItem>
|
|
256
|
-
<ContextMenuSeparator />
|
|
257
|
-
</>
|
|
258
|
-
)}
|
|
259
|
-
<ContextMenuItem onClick={() => onAction("rename", node)}>
|
|
260
|
-
Rename
|
|
261
|
-
</ContextMenuItem>
|
|
262
|
-
<ContextMenuItem
|
|
263
|
-
variant="destructive"
|
|
264
|
-
onClick={() => onAction("delete", node)}
|
|
265
|
-
>
|
|
266
|
-
Delete
|
|
267
|
-
</ContextMenuItem>
|
|
268
|
-
<ContextMenuSeparator />
|
|
269
|
-
<ContextMenuItem onClick={() => onAction("copy-path", node)}>
|
|
270
|
-
Copy Path
|
|
271
|
-
</ContextMenuItem>
|
|
272
|
-
<ContextMenuSeparator />
|
|
273
|
-
<ContextMenuItem onClick={() => onAction("download", node)}>
|
|
274
|
-
<Download className="size-3.5 mr-2" />
|
|
275
|
-
Download{isDir ? " as Zip" : ""}
|
|
276
|
-
</ContextMenuItem>
|
|
277
|
-
{!isDir && (
|
|
278
|
-
<>
|
|
279
|
-
<ContextMenuSeparator />
|
|
280
|
-
<ContextMenuItem onClick={() => onAction("select-for-compare", node)}>
|
|
281
|
-
Select for Compare
|
|
282
|
-
</ContextMenuItem>
|
|
283
|
-
{compareSelection && compareSelection.projectName === projectName && compareSelection.filePath !== node.path && (
|
|
284
|
-
<ContextMenuItem onClick={() => onAction("compare-with-selected", node)}>
|
|
285
|
-
Compare with Selected ({compareSelection.label})
|
|
286
|
-
</ContextMenuItem>
|
|
287
|
-
)}
|
|
288
|
-
</>
|
|
289
|
-
)}
|
|
290
|
-
{!isDir && selectedFiles.length === 2 && (
|
|
291
|
-
<>
|
|
292
|
-
<ContextMenuSeparator />
|
|
293
|
-
<ContextMenuItem onClick={() => onAction("compare-selected", node)}>
|
|
294
|
-
Compare Selected
|
|
295
|
-
</ContextMenuItem>
|
|
296
|
-
</>
|
|
297
|
-
)}
|
|
298
|
-
</ContextMenuContent>
|
|
299
|
-
</ContextMenu>
|
|
300
|
-
|
|
301
|
-
{isDir && isExpanded && sortedChildren.map((child) => (
|
|
302
|
-
<TreeNode
|
|
303
|
-
key={child.path}
|
|
304
|
-
node={child}
|
|
305
|
-
depth={depth + 1}
|
|
306
|
-
projectName={projectName}
|
|
307
|
-
onAction={onAction}
|
|
308
|
-
onFileDrop={onFileDrop}
|
|
309
|
-
onFileOpen={onFileOpen}
|
|
310
|
-
/>
|
|
311
|
-
))}
|
|
312
|
-
</div>
|
|
313
|
-
);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
41
|
interface FileTreeProps {
|
|
317
42
|
onFileOpen?: () => void;
|
|
318
43
|
}
|
|
@@ -322,8 +47,9 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
322
47
|
tree, loading, error,
|
|
323
48
|
loadRoot, loadIndex, loadChildren, invalidateIndex, invalidateFolder,
|
|
324
49
|
reset, selectedFiles, clearSelection, setExpanded,
|
|
325
|
-
|
|
326
|
-
|
|
50
|
+
fetchTree, inlineAction, setInlineAction, clearInlineAction,
|
|
51
|
+
clipboard, setClipboard, collapseAll,
|
|
52
|
+
focusedPath, setFocusedPath, expandedPaths, toggleExpand,
|
|
327
53
|
} = useFileStore(
|
|
328
54
|
useShallow((s) => ({
|
|
329
55
|
tree: s.tree,
|
|
@@ -339,6 +65,16 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
339
65
|
clearSelection: s.clearSelection,
|
|
340
66
|
setExpanded: s.setExpanded,
|
|
341
67
|
fetchTree: s.fetchTree,
|
|
68
|
+
inlineAction: s.inlineAction,
|
|
69
|
+
setInlineAction: s.setInlineAction,
|
|
70
|
+
clearInlineAction: s.clearInlineAction,
|
|
71
|
+
clipboard: s.clipboard,
|
|
72
|
+
setClipboard: s.setClipboard,
|
|
73
|
+
collapseAll: s.collapseAll,
|
|
74
|
+
focusedPath: s.focusedPath,
|
|
75
|
+
setFocusedPath: s.setFocusedPath,
|
|
76
|
+
expandedPaths: s.expandedPaths,
|
|
77
|
+
toggleExpand: s.toggleExpand,
|
|
342
78
|
})),
|
|
343
79
|
);
|
|
344
80
|
const activeProject = useProjectStore((s) => s.activeProject);
|
|
@@ -348,7 +84,6 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
348
84
|
node: FileNode;
|
|
349
85
|
} | null>(null);
|
|
350
86
|
|
|
351
|
-
/** Full reload used by toolbar Refresh button and post-upload */
|
|
352
87
|
const reloadTree = useCallback(() => {
|
|
353
88
|
if (!activeProject) return;
|
|
354
89
|
reset();
|
|
@@ -356,22 +91,90 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
356
91
|
loadIndex(activeProject.name);
|
|
357
92
|
}, [activeProject, reset, loadRoot, loadIndex]);
|
|
358
93
|
|
|
359
|
-
|
|
94
|
+
/** Reveal (scroll to + highlight) the file that's open in the active tab */
|
|
95
|
+
const revealActiveFile = useCallback(async () => {
|
|
96
|
+
if (!activeProject) return;
|
|
97
|
+
const { tabs, activeTabId } = useTabStore.getState();
|
|
98
|
+
const activeTab = tabs.find((t) => t.id === activeTabId);
|
|
99
|
+
const filePath = activeTab?.metadata?.filePath as string | undefined;
|
|
100
|
+
if (!filePath) return;
|
|
101
|
+
|
|
102
|
+
// Expand all parent folders
|
|
103
|
+
const parts = filePath.split("/");
|
|
104
|
+
const projectName = activeProject.name;
|
|
105
|
+
for (let i = 1; i < parts.length; i++) {
|
|
106
|
+
const parentPath = parts.slice(0, i).join("/");
|
|
107
|
+
setExpanded(parentPath, true);
|
|
108
|
+
// Ensure children are loaded
|
|
109
|
+
await loadChildren(projectName, parentPath);
|
|
110
|
+
}
|
|
111
|
+
setFocusedPath(filePath);
|
|
112
|
+
}, [activeProject, setExpanded, loadChildren, setFocusedPath]);
|
|
113
|
+
|
|
114
|
+
/** Paste clipboard files into a target directory */
|
|
115
|
+
const pasteFiles = useCallback(async (targetDir: string) => {
|
|
116
|
+
if (!activeProject || !clipboard) return;
|
|
117
|
+
const projectName = activeProject.name;
|
|
118
|
+
const endpoint = clipboard.operation === "cut" ? "move" : "copy";
|
|
119
|
+
for (const source of clipboard.paths) {
|
|
120
|
+
const name = source.includes("/") ? source.slice(source.lastIndexOf("/") + 1) : source;
|
|
121
|
+
const destination = targetDir ? `${targetDir}/${name}` : name;
|
|
122
|
+
try {
|
|
123
|
+
await api.post(`${projectUrl(projectName)}/files/${endpoint}`, { source, destination });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
toast.error(err instanceof Error ? err.message : `Failed to ${endpoint}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (clipboard.operation === "cut") setClipboard(null);
|
|
129
|
+
reloadTree();
|
|
130
|
+
}, [activeProject, clipboard, setClipboard, reloadTree]);
|
|
131
|
+
|
|
132
|
+
const treeContainerRef = useRef<HTMLDivElement>(null);
|
|
133
|
+
|
|
134
|
+
/** Ctrl+X / Ctrl+C / Ctrl+V — scoped to file tree container focus */
|
|
135
|
+
const handleClipboardKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
136
|
+
if (!activeProject) return;
|
|
137
|
+
const target = e.target as HTMLElement;
|
|
138
|
+
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
|
|
139
|
+
|
|
140
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
141
|
+
if (!mod) return;
|
|
142
|
+
|
|
143
|
+
if (e.key === "x" && selectedFiles.length > 0) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
setClipboard({ paths: [...selectedFiles], operation: "cut" });
|
|
146
|
+
} else if (e.key === "c" && selectedFiles.length > 0) {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
setClipboard({ paths: [...selectedFiles], operation: "copy" });
|
|
149
|
+
} else if (e.key === "v" && clipboard) {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
pasteFiles("");
|
|
152
|
+
}
|
|
153
|
+
}, [activeProject, selectedFiles, clipboard, setClipboard, pasteFiles]);
|
|
154
|
+
|
|
155
|
+
const { handleTreeKeyDown } = useTreeKeyboardNav({
|
|
156
|
+
tree,
|
|
157
|
+
expandedPaths,
|
|
158
|
+
focusedPath,
|
|
159
|
+
setFocusedPath,
|
|
160
|
+
setExpanded,
|
|
161
|
+
toggleExpand,
|
|
162
|
+
projectName: activeProject?.name,
|
|
163
|
+
onAction: handleAction,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// On project switch: reset + load root + load index + auto-expand root
|
|
360
167
|
useEffect(() => {
|
|
361
168
|
if (!activeProject) return;
|
|
362
169
|
reset();
|
|
363
170
|
const name = activeProject.name;
|
|
364
|
-
|
|
365
|
-
// Load root entries, then auto-expand the root node itself (path="")
|
|
366
171
|
loadRoot(name).then(() => {
|
|
367
|
-
// Auto-expand root — marks "" as expanded so root-level dirs show children on next expand
|
|
368
|
-
// Root entries are already visible; no deeper auto-expand per plan decision
|
|
369
172
|
useFileStore.getState().setExpanded("", true);
|
|
370
173
|
});
|
|
371
174
|
loadIndex(name);
|
|
372
175
|
}, [activeProject?.name]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
373
176
|
|
|
374
|
-
// Handle WS file:changed → invalidate folder + index
|
|
177
|
+
// Handle WS file:changed → invalidate folder + index
|
|
375
178
|
useEffect(() => {
|
|
376
179
|
if (!activeProject) return;
|
|
377
180
|
const projectName = activeProject.name;
|
|
@@ -384,7 +187,6 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
384
187
|
clearTimeout(debounceTimer);
|
|
385
188
|
debounceTimer = setTimeout(() => {
|
|
386
189
|
const store = useFileStore.getState();
|
|
387
|
-
// Derive parent folder from changed file path
|
|
388
190
|
const changedPath: string = detail.path ?? "";
|
|
389
191
|
const parentPath = changedPath.includes("/")
|
|
390
192
|
? changedPath.slice(0, changedPath.lastIndexOf("/"))
|
|
@@ -402,67 +204,43 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
402
204
|
};
|
|
403
205
|
}, [activeProject]);
|
|
404
206
|
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
for (const file of files) form.append("files", file);
|
|
410
|
-
const headers: HeadersInit = {};
|
|
411
|
-
const token = getAuthToken();
|
|
412
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
413
|
-
try {
|
|
414
|
-
const res = await fetch(`${projectUrl(activeProject.name)}/files/upload`, {
|
|
415
|
-
method: "POST",
|
|
416
|
-
headers,
|
|
417
|
-
body: form,
|
|
418
|
-
});
|
|
419
|
-
if (!res.ok) {
|
|
420
|
-
const json = await res.json();
|
|
421
|
-
console.error("Upload failed:", json.error);
|
|
422
|
-
}
|
|
423
|
-
// Invalidate the target folder so it refreshes
|
|
424
|
-
const store = useFileStore.getState();
|
|
425
|
-
const folderPath = targetDir;
|
|
426
|
-
const folderLoadedPaths = store.loadedPaths;
|
|
427
|
-
if (folderLoadedPaths.has(folderPath)) {
|
|
428
|
-
const lp = new Set(store.loadedPaths);
|
|
429
|
-
lp.delete(folderPath);
|
|
430
|
-
// Force reload by clearing and re-expanding
|
|
431
|
-
await store.invalidateFolder(activeProject.name, folderPath);
|
|
432
|
-
}
|
|
433
|
-
if (targetDir) setExpanded(targetDir, true);
|
|
434
|
-
} catch (e) {
|
|
435
|
-
console.error("Upload error:", e);
|
|
436
|
-
}
|
|
437
|
-
}, [activeProject, setExpanded]);
|
|
438
|
-
|
|
439
|
-
const [isRootDragOver, setIsRootDragOver] = useState(false);
|
|
440
|
-
const rootDragCounter = useRef(0);
|
|
441
|
-
|
|
442
|
-
function handleRootDragEnter(e: React.DragEvent) {
|
|
443
|
-
if (!isExternalFileDrag(e)) return;
|
|
444
|
-
e.preventDefault();
|
|
445
|
-
rootDragCounter.current++;
|
|
446
|
-
if (rootDragCounter.current === 1) setIsRootDragOver(true);
|
|
447
|
-
}
|
|
448
|
-
function handleRootDragLeave() {
|
|
449
|
-
rootDragCounter.current--;
|
|
450
|
-
if (rootDragCounter.current === 0) setIsRootDragOver(false);
|
|
451
|
-
}
|
|
452
|
-
function handleRootDragOver(e: React.DragEvent) {
|
|
453
|
-
if (!isExternalFileDrag(e)) return;
|
|
454
|
-
e.preventDefault();
|
|
455
|
-
e.dataTransfer.dropEffect = "copy";
|
|
456
|
-
}
|
|
457
|
-
function handleRootDrop(e: React.DragEvent) {
|
|
458
|
-
if (!isExternalFileDrag(e)) return;
|
|
459
|
-
e.preventDefault();
|
|
460
|
-
rootDragCounter.current = 0;
|
|
461
|
-
setIsRootDragOver(false);
|
|
462
|
-
if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
|
|
463
|
-
}
|
|
207
|
+
const {
|
|
208
|
+
uploadFiles, isRootDragOver,
|
|
209
|
+
handleRootDragEnter, handleRootDragLeave, handleRootDragOver, handleRootDrop,
|
|
210
|
+
} = useFileUploadDrag({ projectName: activeProject?.name, setExpanded });
|
|
464
211
|
|
|
465
212
|
async function handleAction(action: string, node: FileNode) {
|
|
213
|
+
if (action === "toggle-expand" && node.type === "directory") {
|
|
214
|
+
toggleExpand(activeProject!.name, node.path);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (action === "open-file" && node.type === "file") {
|
|
218
|
+
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
|
|
219
|
+
const isSqlite = ext === "db" || ext === "sqlite" || ext === "sqlite3";
|
|
220
|
+
openTab({
|
|
221
|
+
type: isSqlite ? "sqlite" : "editor",
|
|
222
|
+
title: node.name,
|
|
223
|
+
metadata: { filePath: node.path, projectName: activeProject!.name },
|
|
224
|
+
projectId: activeProject!.name,
|
|
225
|
+
closable: true,
|
|
226
|
+
});
|
|
227
|
+
onFileOpen?.();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (action === "cut") {
|
|
231
|
+
const paths = selectedFiles.length > 0 && selectedFiles.includes(node.path) ? [...selectedFiles] : [node.path];
|
|
232
|
+
setClipboard({ paths, operation: "cut" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (action === "copy-file") {
|
|
236
|
+
const paths = selectedFiles.length > 0 && selectedFiles.includes(node.path) ? [...selectedFiles] : [node.path];
|
|
237
|
+
setClipboard({ paths, operation: "copy" });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (action === "paste" && node.type === "directory") {
|
|
241
|
+
pasteFiles(node.path);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
466
244
|
if (action === "copy-path") {
|
|
467
245
|
navigator.clipboard.writeText(node.path).catch(() => {});
|
|
468
246
|
return;
|
|
@@ -518,6 +296,19 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
518
296
|
clearSelection();
|
|
519
297
|
return;
|
|
520
298
|
}
|
|
299
|
+
if (action === "new-file" || action === "new-folder") {
|
|
300
|
+
const parentPath = node.type === "directory" ? node.path : "";
|
|
301
|
+
if (parentPath) setExpanded(parentPath, true);
|
|
302
|
+
setInlineAction({ type: action as "new-file" | "new-folder", parentPath });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (action === "rename") {
|
|
306
|
+
const parentPath = node.path.includes("/")
|
|
307
|
+
? node.path.slice(0, node.path.lastIndexOf("/"))
|
|
308
|
+
: "";
|
|
309
|
+
setInlineAction({ type: "rename", parentPath, existingNode: node });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
521
312
|
setActionState({ action, node });
|
|
522
313
|
}
|
|
523
314
|
|
|
@@ -558,14 +349,17 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
558
349
|
|
|
559
350
|
return (
|
|
560
351
|
<div
|
|
561
|
-
|
|
352
|
+
ref={treeContainerRef}
|
|
353
|
+
className={cn("flex flex-col h-full outline-none", isRootDragOver && "bg-primary/5")}
|
|
354
|
+
tabIndex={0}
|
|
355
|
+
onKeyDown={(e) => { handleClipboardKeyDown(e); handleTreeKeyDown(e); }}
|
|
562
356
|
onDragEnter={handleRootDragEnter}
|
|
563
357
|
onDragLeave={handleRootDragLeave}
|
|
564
358
|
onDragOver={handleRootDragOver}
|
|
565
359
|
onDrop={handleRootDrop}
|
|
566
360
|
>
|
|
567
361
|
{/* Toolbar */}
|
|
568
|
-
<div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0">
|
|
362
|
+
<div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0 sticky top-0 z-10 bg-surface">
|
|
569
363
|
<button onClick={() => handleAction("new-file", ROOT_NODE)} title="New File" className={toolbarBtnClass}>
|
|
570
364
|
<FilePlus className="size-3.5" />
|
|
571
365
|
</button>
|
|
@@ -573,6 +367,12 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
573
367
|
<FolderPlus className="size-3.5" />
|
|
574
368
|
</button>
|
|
575
369
|
<div className="flex-1" />
|
|
370
|
+
<button onClick={revealActiveFile} title="Reveal Active File" className={toolbarBtnClass}>
|
|
371
|
+
<Crosshair className="size-3.5" />
|
|
372
|
+
</button>
|
|
373
|
+
<button onClick={collapseAll} title="Collapse All" className={toolbarBtnClass}>
|
|
374
|
+
<ChevronsDownUp className="size-3.5" />
|
|
375
|
+
</button>
|
|
576
376
|
<button onClick={reloadTree} title="Refresh" className={toolbarBtnClass}>
|
|
577
377
|
<RefreshCw className="size-3.5" />
|
|
578
378
|
</button>
|
|
@@ -583,6 +383,21 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
583
383
|
<ContextMenuTrigger asChild>
|
|
584
384
|
<ScrollArea className="flex-1">
|
|
585
385
|
<div className="py-1">
|
|
386
|
+
{inlineAction && inlineAction.parentPath === "" && inlineAction.type !== "rename" && (
|
|
387
|
+
<InlineTreeInput
|
|
388
|
+
defaultValue=""
|
|
389
|
+
placeholder={inlineAction.type === "new-file" ? "filename.ts" : "folder-name"}
|
|
390
|
+
depth={0}
|
|
391
|
+
icon={inlineAction.type === "new-file" ? "file" : "folder"}
|
|
392
|
+
onConfirm={async (name) => {
|
|
393
|
+
const type = inlineAction.type === "new-file" ? "file" : "directory";
|
|
394
|
+
await api.post(`${projectUrl(activeProject!.name)}/files/create`, { path: name, type });
|
|
395
|
+
clearInlineAction();
|
|
396
|
+
reloadTree();
|
|
397
|
+
}}
|
|
398
|
+
onCancel={clearInlineAction}
|
|
399
|
+
/>
|
|
400
|
+
)}
|
|
586
401
|
{sorted.map((node) => (
|
|
587
402
|
<TreeNode
|
|
588
403
|
key={node.path}
|
|
@@ -617,9 +432,9 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
617
432
|
</ContextMenuContent>
|
|
618
433
|
</ContextMenu>
|
|
619
434
|
|
|
620
|
-
{actionState && (
|
|
435
|
+
{actionState?.action === "delete" && (
|
|
621
436
|
<FileActions
|
|
622
|
-
action=
|
|
437
|
+
action="delete"
|
|
623
438
|
node={actionState.node}
|
|
624
439
|
projectName={activeProject.name}
|
|
625
440
|
onClose={() => setActionState(null)}
|