@hienlh/ppm 0.17.40 → 0.17.42
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 +12 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-bGSN2dFv.js → ai-resource-editor-BFpX8O8g.js} +1 -1
- package/dist/web/assets/{audio-preview-DnSpaaBX.js → audio-preview-CCszWhE7.js} +1 -1
- package/dist/web/assets/chat-tab-rSC30FXc.js +13 -0
- package/dist/web/assets/code-editor-Br-uzog8.js +10 -0
- package/dist/web/assets/{conflict-editor-LDIdkUJm.js → conflict-editor-JlKPk6Pr.js} +3 -3
- package/dist/web/assets/csv-preview-5RP4HsC0.js +2 -0
- package/dist/web/assets/{database-viewer-RkxcBpY3.js → database-viewer-DSTjX9zV.js} +1 -1
- package/dist/web/assets/{diff-viewer-YTRJpdV6.js → diff-viewer-Dm715N42.js} +1 -1
- package/dist/web/assets/{docx-preview-B4B4W5LU.js → docx-preview-QeS2QgmK.js} +1 -1
- package/dist/web/assets/{extension-webview-CB8-nHNA.js → extension-webview-mwQAJuOv.js} +1 -1
- package/dist/web/assets/{git-log-panel-OXE-EtxM.js → git-log-panel-Bs8PPvDI.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CCVPfJtk.js → glide-data-grid-DYQLp-gP.js} +4 -4
- package/dist/web/assets/group-chat-tab--0hGwVFa.js +1 -0
- package/dist/web/assets/{image-preview-CenSD210.js → image-preview-DahiriA8.js} +1 -1
- package/dist/web/assets/index-BOhGyv5C.js +50 -0
- package/dist/web/assets/keybindings-store-pi3VOJGw.js +1 -0
- package/dist/web/assets/{markdown-renderer-CAFmdvwK.js → markdown-renderer-C8PH2HyN.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CyzYLWuu.js → markdown-renderer-CSeQnHB5.js} +2 -2
- package/dist/web/assets/notification-store-D6QMHrX0.js +1 -0
- package/dist/web/assets/{pdf-preview-CsOL51Lb.js → pdf-preview-ChAi-S2U.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Dtt-XLSP.js → postgres-viewer-MIPyKZ_B.js} +2 -2
- package/dist/web/assets/{settings-tab-BR9fp2pd.js → settings-tab-bzAngl0_.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BwquzTWY.js → sql-query-editor-CG8tbxLG.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-YO4IEGzt.js → sqlite-viewer-DxqQyT9P.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-C-WT_FG3.js → system-monitor-tab-z708TL13.js} +1 -1
- package/dist/web/assets/{terminal-tab-BqW8reuZ.js → terminal-tab-EwgucrX3.js} +2 -2
- package/dist/web/assets/{tool-cards-DtF80k3f.js → tool-cards-4hL8H_UL.js} +3 -3
- package/dist/web/assets/{use-monaco-theme-CF_57XHm.js → use-monaco-theme-DT_GPTTP.js} +1 -1
- package/dist/web/assets/{video-preview-OoDYJ8U4.js → video-preview-r0pg-GlA.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/routes/files.ts +24 -0
- package/src/services/file-list-index.service.ts +34 -3
- package/src/services/file.service.ts +6 -0
- package/src/services/windows-zombie-port-reaper.ts +6 -0
- package/src/web/components/explorer/file-tree.tsx +115 -44
- package/src/web/components/explorer/flatten-visible-tree.ts +102 -0
- package/src/web/components/explorer/tree-node.tsx +82 -166
- package/src/web/components/explorer/use-tree-keyboard-nav.ts +3 -30
- package/src/web/stores/file-store.ts +101 -29
- package/src/web/stores/file-tree-prefetch.ts +57 -0
- package/dist/web/assets/chat-tab-Cvuo7XWm.js +0 -13
- package/dist/web/assets/code-editor-BvwCPxaD.js +0 -10
- package/dist/web/assets/csv-preview-47tAvhIq.js +0 -2
- package/dist/web/assets/group-chat-tab-D5rkf_zG.js +0 -1
- package/dist/web/assets/index-bQy6gwMT.js +0 -50
- package/dist/web/assets/keybindings-store-Bk2X4njD.js +0 -1
- package/dist/web/assets/notification-store-C9MS_FlD.js +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* TreeRow component — renders a single file/folder row in the virtualized explorer tree.
|
|
3
|
+
* Rows are flat siblings (no recursion); visible order comes from flatten-visible-tree.ts.
|
|
3
4
|
* Handles click, drag/drop, context menu for individual tree items.
|
|
4
5
|
*/
|
|
5
6
|
import { useState, useRef, useEffect, memo } from "react";
|
|
@@ -11,37 +12,41 @@ import {
|
|
|
11
12
|
Loader2,
|
|
12
13
|
} from "lucide-react";
|
|
13
14
|
import { useShallow } from "zustand/react/shallow";
|
|
14
|
-
import { useFileStore, getVisiblePaths, type FileNode
|
|
15
|
+
import { useFileStore, getVisiblePaths, type FileNode } from "@/stores/file-store";
|
|
15
16
|
import { useTabStore } from "@/stores/tab-store";
|
|
16
17
|
import { useCompareStore } from "@/stores/compare-store";
|
|
17
18
|
import { useGitStatusStore, GIT_STATUS_COLORS, type GitFileStatus } from "@/stores/git-status-store";
|
|
18
19
|
import { api, projectUrl } from "@/lib/api-client";
|
|
19
20
|
import { cn } from "@/lib/utils";
|
|
20
21
|
import { toast } from "sonner";
|
|
21
|
-
import { InlineTreeInput } from "./inline-tree-input";
|
|
22
22
|
import {
|
|
23
23
|
ContextMenu,
|
|
24
24
|
ContextMenuTrigger,
|
|
25
25
|
} from "@/components/ui/adaptive-context-menu";
|
|
26
26
|
import { getFileIcon } from "./file-icon-map";
|
|
27
27
|
import { TreeNodeContextMenu } from "./tree-node-context-menu";
|
|
28
|
+
import type { NodeRow } from "./flatten-visible-tree";
|
|
28
29
|
|
|
29
30
|
/** Check if drag event is from OS files (not internal PPM drag) */
|
|
30
31
|
export function isExternalFileDrag(e: React.DragEvent): boolean {
|
|
31
32
|
return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
function parentDirOf(path: string): string {
|
|
36
|
+
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TreeRowProps {
|
|
40
|
+
row: NodeRow;
|
|
37
41
|
projectName: string;
|
|
38
42
|
onAction: (action: string, node: FileNode) => void;
|
|
39
43
|
onFileDrop: (targetDir: string, files: FileList) => void;
|
|
40
44
|
onFileOpen?: () => void;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
export const
|
|
44
|
-
const {
|
|
47
|
+
export const TreeRow = memo(function TreeRow({ row, projectName, onAction, onFileDrop, onFileOpen }: TreeRowProps) {
|
|
48
|
+
const { node, effectiveNode, displayName, depth } = row;
|
|
49
|
+
const { expandedPaths, loadedPaths, inflight, toggleExpand, selectedFiles, toggleFileSelect, clipboard, focusedPath, setFocusedPath } = useFileStore(
|
|
45
50
|
useShallow((s) => ({
|
|
46
51
|
expandedPaths: s.expandedPaths,
|
|
47
52
|
loadedPaths: s.loadedPaths,
|
|
@@ -49,8 +54,6 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
49
54
|
toggleExpand: s.toggleExpand,
|
|
50
55
|
selectedFiles: s.selectedFiles,
|
|
51
56
|
toggleFileSelect: s.toggleFileSelect,
|
|
52
|
-
inlineAction: s.inlineAction,
|
|
53
|
-
clearInlineAction: s.clearInlineAction,
|
|
54
57
|
clipboard: s.clipboard,
|
|
55
58
|
focusedPath: s.focusedPath,
|
|
56
59
|
setFocusedPath: s.setFocusedPath,
|
|
@@ -69,8 +72,8 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
69
72
|
const isSelected = selectedFiles.includes(node.path);
|
|
70
73
|
const isIgnored = node.ignored === true;
|
|
71
74
|
const isCut = clipboard?.operation === "cut" && clipboard.paths.includes(node.path);
|
|
72
|
-
const isFocused = focusedPath === node.path;
|
|
73
|
-
const isLoadingChildren = isDir && isExpanded && !loadedPaths.has(
|
|
75
|
+
const isFocused = focusedPath === node.path || focusedPath === effectiveNode.path;
|
|
76
|
+
const isLoadingChildren = isDir && isExpanded && !loadedPaths.has(effectiveNode.path) && inflight.has(effectiveNode.path);
|
|
74
77
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
75
78
|
const dragCounter = useRef(0);
|
|
76
79
|
const rowRef = useRef<HTMLButtonElement>(null);
|
|
@@ -92,7 +95,7 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
92
95
|
if (e.shiftKey && focusedPath != null) {
|
|
93
96
|
const paths = getVisiblePaths();
|
|
94
97
|
const fromIdx = paths.indexOf(focusedPath);
|
|
95
|
-
const toIdx = paths.indexOf(
|
|
98
|
+
const toIdx = paths.indexOf(effectiveNode.path);
|
|
96
99
|
if (fromIdx >= 0 && toIdx >= 0) {
|
|
97
100
|
const start = Math.min(fromIdx, toIdx);
|
|
98
101
|
const end = Math.max(fromIdx, toIdx);
|
|
@@ -126,9 +129,10 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
126
129
|
e.dataTransfer.effectAllowed = "copyMove";
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
|
|
132
|
+
// Flat rows: dropping on a file targets its parent directory
|
|
133
|
+
const dropTargetDir = isDir ? effectiveNode.path : parentDirOf(node.path);
|
|
134
|
+
|
|
130
135
|
function canAcceptDrop(e: React.DragEvent): boolean {
|
|
131
|
-
if (!isDir) return false;
|
|
132
136
|
return isExternalFileDrag(e) || e.dataTransfer.types.includes("application/x-ppm-path");
|
|
133
137
|
}
|
|
134
138
|
|
|
@@ -140,7 +144,6 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
140
144
|
if (dragCounter.current === 1) setIsDragOver(true);
|
|
141
145
|
}
|
|
142
146
|
function handleNodeDragLeave(e: React.DragEvent) {
|
|
143
|
-
if (!isDir) return;
|
|
144
147
|
e.stopPropagation();
|
|
145
148
|
dragCounter.current--;
|
|
146
149
|
if (dragCounter.current === 0) setIsDragOver(false);
|
|
@@ -152,7 +155,6 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
152
155
|
e.dataTransfer.dropEffect = isExternalFileDrag(e) ? "copy" : "move";
|
|
153
156
|
}
|
|
154
157
|
function handleNodeDrop(e: React.DragEvent) {
|
|
155
|
-
if (!isDir) return;
|
|
156
158
|
e.preventDefault();
|
|
157
159
|
e.stopPropagation();
|
|
158
160
|
dragCounter.current = 0;
|
|
@@ -160,7 +162,7 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
160
162
|
|
|
161
163
|
// External file upload
|
|
162
164
|
if (isExternalFileDrag(e)) {
|
|
163
|
-
if (e.dataTransfer.files.length > 0) onFileDrop(
|
|
165
|
+
if (e.dataTransfer.files.length > 0) onFileDrop(dropTargetDir, e.dataTransfer.files);
|
|
164
166
|
return;
|
|
165
167
|
}
|
|
166
168
|
|
|
@@ -168,20 +170,20 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
168
170
|
const sourcePath = e.dataTransfer.getData("application/x-ppm-path").replace(/\/$/, "");
|
|
169
171
|
if (!sourcePath) return;
|
|
170
172
|
// Prevent dropping into self or descendant
|
|
171
|
-
if (sourcePath ===
|
|
173
|
+
if (sourcePath === dropTargetDir || dropTargetDir.startsWith(`${sourcePath}/`)) return;
|
|
172
174
|
// Prevent no-op (already in this folder)
|
|
173
|
-
const sourceParent =
|
|
174
|
-
if (sourceParent ===
|
|
175
|
+
const sourceParent = parentDirOf(sourcePath);
|
|
176
|
+
if (sourceParent === dropTargetDir) return;
|
|
175
177
|
|
|
176
178
|
const sourceName = sourcePath.includes("/") ? sourcePath.slice(sourcePath.lastIndexOf("/") + 1) : sourcePath;
|
|
177
|
-
const destination =
|
|
179
|
+
const destination = dropTargetDir ? `${dropTargetDir}/${sourceName}` : sourceName;
|
|
178
180
|
api.post(`${projectUrl(projectName)}/files/move`, { source: sourcePath, destination })
|
|
179
181
|
.then(() => {
|
|
180
182
|
const store = useFileStore.getState();
|
|
181
183
|
store.invalidateIndex();
|
|
182
184
|
store.loadIndex(projectName);
|
|
183
185
|
store.invalidateFolder(projectName, sourceParent);
|
|
184
|
-
store.invalidateFolder(projectName,
|
|
186
|
+
store.invalidateFolder(projectName, dropTargetDir);
|
|
185
187
|
})
|
|
186
188
|
.catch((err) => {
|
|
187
189
|
toast.error(err instanceof Error ? err.message : "Move failed");
|
|
@@ -192,159 +194,73 @@ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAct
|
|
|
192
194
|
? { icon: isExpanded ? FolderOpen : Folder, color: isExpanded ? "text-primary" : "text-text-3" }
|
|
193
195
|
: getFileIcon(node.name);
|
|
194
196
|
|
|
195
|
-
// Compact folders: collapse single-child dir chains into "a/b/c" display
|
|
196
|
-
let displayName = node.name;
|
|
197
|
-
let effectiveNode = node;
|
|
198
|
-
if (isDir && isExpanded && node.children) {
|
|
199
|
-
let current = node;
|
|
200
|
-
while (
|
|
201
|
-
current.children &&
|
|
202
|
-
current.children.length === 1 &&
|
|
203
|
-
current.children[0]!.type === "directory" &&
|
|
204
|
-
expandedPaths.has(current.children[0]!.path)
|
|
205
|
-
) {
|
|
206
|
-
current = current.children[0]!;
|
|
207
|
-
displayName += `/${current.name}`;
|
|
208
|
-
}
|
|
209
|
-
if (current !== node) effectiveNode = current;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const sortedChildren = effectiveNode.children
|
|
213
|
-
? [...effectiveNode.children].sort((a, b) => {
|
|
214
|
-
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
215
|
-
return a.name.localeCompare(b.name);
|
|
216
|
-
})
|
|
217
|
-
: [];
|
|
218
|
-
|
|
219
|
-
const isRenaming = inlineAction?.type === "rename" && inlineAction.existingNode?.path === node.path;
|
|
220
|
-
const isCreatingHere = isDir && inlineAction != null && (inlineAction.parentPath === node.path || inlineAction.parentPath === effectiveNode.path) && inlineAction.type !== "rename";
|
|
221
|
-
|
|
222
197
|
return (
|
|
223
198
|
<div
|
|
224
|
-
onDragEnter={
|
|
225
|
-
onDragLeave={
|
|
226
|
-
onDragOver={
|
|
227
|
-
onDrop={
|
|
199
|
+
onDragEnter={handleNodeDragEnter}
|
|
200
|
+
onDragLeave={handleNodeDragLeave}
|
|
201
|
+
onDragOver={handleNodeDragOver}
|
|
202
|
+
onDrop={handleNodeDrop}
|
|
228
203
|
>
|
|
229
|
-
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
onDragStart={handleDragStart}
|
|
260
|
-
onClick={handleClick}
|
|
204
|
+
<ContextMenu>
|
|
205
|
+
<ContextMenuTrigger asChild>
|
|
206
|
+
<button
|
|
207
|
+
ref={rowRef}
|
|
208
|
+
draggable
|
|
209
|
+
onDragStart={handleDragStart}
|
|
210
|
+
onClick={handleClick}
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex items-center w-full gap-1.5 px-2 py-1 rounded-[var(--rad-sm)] text-[13px]",
|
|
213
|
+
"min-h-[32px] md:min-h-[26px] hover:bg-surface-elevated transition-colors text-left",
|
|
214
|
+
"select-none",
|
|
215
|
+
(isIgnored || isCut) && "opacity-40",
|
|
216
|
+
isFocused && "bg-surface-elevated",
|
|
217
|
+
isSelected && "bg-accent-wash",
|
|
218
|
+
isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
|
|
219
|
+
)}
|
|
220
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
221
|
+
>
|
|
222
|
+
{isDir ? (
|
|
223
|
+
isLoadingChildren ? (
|
|
224
|
+
<Loader2 className="size-3.5 shrink-0 text-text-subtle animate-spin" />
|
|
225
|
+
) : isExpanded ? (
|
|
226
|
+
<ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
|
|
227
|
+
) : (
|
|
228
|
+
<ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
|
|
229
|
+
)
|
|
230
|
+
) : (
|
|
231
|
+
<span className="w-3.5 shrink-0" />
|
|
232
|
+
)}
|
|
233
|
+
<FileIcon
|
|
261
234
|
className={cn(
|
|
262
|
-
"
|
|
263
|
-
|
|
264
|
-
"select-none",
|
|
265
|
-
(isIgnored || isCut) && "opacity-40",
|
|
266
|
-
isFocused && "bg-surface-elevated",
|
|
267
|
-
isSelected && "bg-accent-wash",
|
|
268
|
-
isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
|
|
235
|
+
"size-4 shrink-0",
|
|
236
|
+
fileIconColor ?? "text-text-secondary",
|
|
269
237
|
)}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
{
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
) : isExpanded ? (
|
|
276
|
-
<ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
|
|
277
|
-
) : (
|
|
278
|
-
<ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
|
|
279
|
-
)
|
|
280
|
-
) : (
|
|
281
|
-
<span className="w-3.5 shrink-0" />
|
|
238
|
+
/>
|
|
239
|
+
<span
|
|
240
|
+
className={cn(
|
|
241
|
+
"truncate",
|
|
242
|
+
gitColor ?? (isSelected ? "text-text" : isDir && isExpanded ? "text-text font-medium" : "text-text-2"),
|
|
282
243
|
)}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
<span
|
|
290
|
-
className={cn(
|
|
291
|
-
"truncate",
|
|
292
|
-
gitColor ?? (isSelected ? "text-text" : isDir && isExpanded ? "text-text font-medium" : "text-text-2"),
|
|
293
|
-
)}
|
|
294
|
-
>
|
|
295
|
-
{displayName}
|
|
244
|
+
>
|
|
245
|
+
{displayName}
|
|
246
|
+
</span>
|
|
247
|
+
{gitStatus && !isDir && (
|
|
248
|
+
<span className={cn("text-[10px] ml-auto shrink-0 font-mono", gitColor)}>
|
|
249
|
+
{gitStatus}
|
|
296
250
|
</span>
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
</ContextMenuTrigger>
|
|
304
|
-
<TreeNodeContextMenu
|
|
305
|
-
node={node}
|
|
306
|
-
isDir={isDir}
|
|
307
|
-
projectName={projectName}
|
|
308
|
-
selectedFiles={selectedFiles}
|
|
309
|
-
compareSelection={compareSelection}
|
|
310
|
-
clipboard={clipboard}
|
|
311
|
-
onAction={onAction}
|
|
312
|
-
/>
|
|
313
|
-
</ContextMenu>
|
|
314
|
-
)}
|
|
315
|
-
|
|
316
|
-
{isDir && isExpanded && isCreatingHere && (
|
|
317
|
-
<InlineTreeInput
|
|
318
|
-
defaultValue=""
|
|
319
|
-
placeholder={inlineAction!.type === "new-file" ? "filename.ts" : "folder-name"}
|
|
320
|
-
depth={depth + 1}
|
|
321
|
-
icon={inlineAction!.type === "new-file" ? "file" : "folder"}
|
|
322
|
-
onConfirm={async (name) => {
|
|
323
|
-
const type = inlineAction!.type === "new-file" ? "file" : "directory";
|
|
324
|
-
const targetPath = effectiveNode.path || node.path;
|
|
325
|
-
const fullPath = targetPath ? `${targetPath}/${name}` : name;
|
|
326
|
-
await api.post(`${projectUrl(projectName)}/files/create`, { path: fullPath, type });
|
|
327
|
-
clearInlineAction();
|
|
328
|
-
const store = useFileStore.getState();
|
|
329
|
-
store.invalidateIndex();
|
|
330
|
-
store.loadIndex(projectName);
|
|
331
|
-
store.invalidateFolder(projectName, targetPath);
|
|
332
|
-
}}
|
|
333
|
-
onCancel={clearInlineAction}
|
|
334
|
-
/>
|
|
335
|
-
)}
|
|
336
|
-
|
|
337
|
-
{isDir && isExpanded && sortedChildren.map((child) => (
|
|
338
|
-
<TreeNode
|
|
339
|
-
key={child.path}
|
|
340
|
-
node={child}
|
|
341
|
-
depth={depth + 1}
|
|
251
|
+
)}
|
|
252
|
+
</button>
|
|
253
|
+
</ContextMenuTrigger>
|
|
254
|
+
<TreeNodeContextMenu
|
|
255
|
+
node={node}
|
|
256
|
+
isDir={isDir}
|
|
342
257
|
projectName={projectName}
|
|
258
|
+
selectedFiles={selectedFiles}
|
|
259
|
+
compareSelection={compareSelection}
|
|
260
|
+
clipboard={clipboard}
|
|
343
261
|
onAction={onAction}
|
|
344
|
-
onFileDrop={onFileDrop}
|
|
345
|
-
onFileOpen={onFileOpen}
|
|
346
262
|
/>
|
|
347
|
-
|
|
263
|
+
</ContextMenu>
|
|
348
264
|
</div>
|
|
349
265
|
);
|
|
350
266
|
});
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Arrow keys, Enter, F2, Delete on focused tree items.
|
|
4
4
|
*/
|
|
5
5
|
import { useMemo, type KeyboardEvent } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { type FileNode } from "@/stores/file-store";
|
|
7
|
+
import { visibleNodesOf } from "./flatten-visible-tree";
|
|
7
8
|
|
|
8
9
|
interface UseTreeKeyboardNavOptions {
|
|
9
10
|
tree: FileNode[];
|
|
@@ -27,35 +28,7 @@ export function useTreeKeyboardNav({
|
|
|
27
28
|
onAction,
|
|
28
29
|
}: UseTreeKeyboardNavOptions) {
|
|
29
30
|
/** Flat list of visible nodes (respects expand state and compact folders) */
|
|
30
|
-
const visibleNodes = useMemo(() =>
|
|
31
|
-
const result: FileNode[] = [];
|
|
32
|
-
function walk(nodes: FileNode[]) {
|
|
33
|
-
const sorted = [...nodes].sort((a, b) => {
|
|
34
|
-
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
35
|
-
return a.name.localeCompare(b.name);
|
|
36
|
-
});
|
|
37
|
-
for (const n of sorted) {
|
|
38
|
-
// Skip compacted intermediate dirs (single-child chains rendered as one row)
|
|
39
|
-
let effective = n;
|
|
40
|
-
if (n.type === "directory" && expandedPaths.has(n.path) && n.children) {
|
|
41
|
-
while (
|
|
42
|
-
effective.children &&
|
|
43
|
-
effective.children.length === 1 &&
|
|
44
|
-
effective.children[0]!.type === "directory" &&
|
|
45
|
-
expandedPaths.has(effective.children[0]!.path)
|
|
46
|
-
) {
|
|
47
|
-
effective = effective.children[0]!;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
result.push(effective);
|
|
51
|
-
if (effective.type === "directory" && expandedPaths.has(effective.path) && effective.children) {
|
|
52
|
-
walk(effective.children);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
walk(tree);
|
|
57
|
-
return result;
|
|
58
|
-
}, [tree, expandedPaths]);
|
|
31
|
+
const visibleNodes = useMemo(() => visibleNodesOf(tree, expandedPaths), [tree, expandedPaths]);
|
|
59
32
|
|
|
60
33
|
const focusedNode = useMemo(
|
|
61
34
|
() => visibleNodes.find((n) => n.path === focusedPath) ?? null,
|
|
@@ -2,6 +2,8 @@ import { create } from "zustand";
|
|
|
2
2
|
import { api, projectUrl } from "@/lib/api-client";
|
|
3
3
|
import type { FileEntry, FileDirEntry } from "../../types/project";
|
|
4
4
|
import { entriesToNodes, mergeChildren } from "./file-tree-merge-helpers";
|
|
5
|
+
import { schedulePrefetch, cancelPrefetch } from "./file-tree-prefetch";
|
|
6
|
+
import { visibleNodesOf } from "@/components/explorer/flatten-visible-tree";
|
|
5
7
|
|
|
6
8
|
export type { FileEntry };
|
|
7
9
|
|
|
@@ -51,7 +53,9 @@ interface FileStore {
|
|
|
51
53
|
setClipboard(clipboard: ClipboardState | null): void;
|
|
52
54
|
setFocusedPath(path: string | null): void;
|
|
53
55
|
loadRoot(projectName: string): Promise<void>;
|
|
54
|
-
loadChildren(projectName: string, folderPath: string): Promise<void>;
|
|
56
|
+
loadChildren(projectName: string, folderPath: string, opts?: { prefetch?: boolean }): Promise<void>;
|
|
57
|
+
/** Load many folders in one request (expanded-state restore, deep expand) */
|
|
58
|
+
loadPathsBatch(projectName: string, paths: string[]): Promise<void>;
|
|
55
59
|
loadIndex(projectName: string): Promise<void>;
|
|
56
60
|
invalidateIndex(): void;
|
|
57
61
|
invalidateFolder(projectName: string, folderPath: string): Promise<void>;
|
|
@@ -66,6 +70,30 @@ interface FileStore {
|
|
|
66
70
|
fetchTree(projectName: string): Promise<void>;
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/** Locate a node in the lazy tree by its project-relative path */
|
|
74
|
+
function findNodeByPath(tree: FileNode[], path: string): FileNode | undefined {
|
|
75
|
+
let nodes = tree;
|
|
76
|
+
for (;;) {
|
|
77
|
+
const node = nodes.find((n) => n.path === path || path.startsWith(`${n.path}/`));
|
|
78
|
+
if (!node) return undefined;
|
|
79
|
+
if (node.path === path) return node;
|
|
80
|
+
nodes = node.children ?? [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Enqueue idle prefetch of the given nodes' children (dirs only, skips ignored) */
|
|
85
|
+
function queuePrefetchFor(
|
|
86
|
+
get: () => FileStore,
|
|
87
|
+
projectName: string,
|
|
88
|
+
children: FileNode[],
|
|
89
|
+
): void {
|
|
90
|
+
schedulePrefetch(
|
|
91
|
+
children,
|
|
92
|
+
(path) => get().loadedPaths.has(path) || get().inflight.has(path),
|
|
93
|
+
(path) => get().loadChildren(projectName, path, { prefetch: true }),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
export const useFileStore = create<FileStore>((set, get) => ({
|
|
70
98
|
tree: [],
|
|
71
99
|
fileIndex: [],
|
|
@@ -95,6 +123,7 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
95
123
|
const loadedPaths = new Set(get().loadedPaths);
|
|
96
124
|
loadedPaths.add(""); // root is loaded
|
|
97
125
|
set({ tree: rootNodes, loading: false, loadedPaths });
|
|
126
|
+
queuePrefetchFor(get, projectName, rootNodes);
|
|
98
127
|
} catch (err) {
|
|
99
128
|
set({
|
|
100
129
|
error: err instanceof Error ? err.message : "Failed to load files",
|
|
@@ -103,11 +132,13 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
103
132
|
}
|
|
104
133
|
},
|
|
105
134
|
|
|
106
|
-
loadChildren: async (projectName: string, folderPath: string) => {
|
|
135
|
+
loadChildren: async (projectName: string, folderPath: string, opts?: { prefetch?: boolean }) => {
|
|
107
136
|
const state = get();
|
|
108
137
|
|
|
109
138
|
// Idempotent guard — skip if already loaded
|
|
110
139
|
if (state.loadedPaths.has(folderPath)) return;
|
|
140
|
+
// Prefetch never preempts an in-flight user-initiated request
|
|
141
|
+
if (opts?.prefetch && state.inflight.has(folderPath)) return;
|
|
111
142
|
|
|
112
143
|
// Abort any existing in-flight request for this path
|
|
113
144
|
const existing = state.inflight.get(folderPath);
|
|
@@ -136,6 +167,8 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
136
167
|
const newInflight = new Map(currentState.inflight);
|
|
137
168
|
newInflight.delete(folderPath);
|
|
138
169
|
set({ tree: newTree, loadedPaths: newLoadedPaths, inflight: newInflight });
|
|
170
|
+
// One level ahead only: prefetched loads don't cascade further
|
|
171
|
+
if (!opts?.prefetch) queuePrefetchFor(get, projectName, children);
|
|
139
172
|
} catch (err) {
|
|
140
173
|
if (err instanceof Error && err.name === "AbortError") return;
|
|
141
174
|
// Remove from inflight on error
|
|
@@ -145,6 +178,44 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
145
178
|
}
|
|
146
179
|
},
|
|
147
180
|
|
|
181
|
+
loadPathsBatch: async (projectName: string, paths: string[]) => {
|
|
182
|
+
const state = get();
|
|
183
|
+
const toLoad = [...new Set(paths)].filter((p) => !state.loadedPaths.has(p));
|
|
184
|
+
if (toLoad.length === 0) return;
|
|
185
|
+
if (state.tree.length === 0) set({ loading: true, error: null });
|
|
186
|
+
try {
|
|
187
|
+
const results = await api.post<{ path: string; entries?: FileDirEntry[]; error?: string }[]>(
|
|
188
|
+
`${projectUrl(projectName)}/files/list-batch`,
|
|
189
|
+
{ paths: toLoad },
|
|
190
|
+
);
|
|
191
|
+
const current = get();
|
|
192
|
+
let newTree = current.tree;
|
|
193
|
+
const newLoaded = new Set(current.loadedPaths);
|
|
194
|
+
const newExpanded = new Set(current.expandedPaths);
|
|
195
|
+
// Parents before children so mergeChildren always finds its target node
|
|
196
|
+
const ordered = [...results].sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
|
197
|
+
for (const r of ordered) {
|
|
198
|
+
if (r.error != null || !r.entries) {
|
|
199
|
+
// Stale persisted path (deleted folder) — drop from expanded state
|
|
200
|
+
newExpanded.delete(r.path);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const children = entriesToNodes(r.entries, r.path);
|
|
204
|
+
newTree = r.path === "" ? children : mergeChildren(newTree, r.path, children);
|
|
205
|
+
newLoaded.add(r.path);
|
|
206
|
+
}
|
|
207
|
+
set({ tree: newTree, loadedPaths: newLoaded, expandedPaths: newExpanded, loading: false });
|
|
208
|
+
// Stay one level ahead of the restored view
|
|
209
|
+
const rootChildren = newTree;
|
|
210
|
+
queuePrefetchFor(get, projectName, rootChildren);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
set({
|
|
213
|
+
error: get().tree.length === 0 ? (err instanceof Error ? err.message : "Failed to load files") : null,
|
|
214
|
+
loading: false,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
|
|
148
219
|
loadIndex: async (projectName: string) => {
|
|
149
220
|
set({ indexStatus: "loading" });
|
|
150
221
|
try {
|
|
@@ -190,6 +261,10 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
190
261
|
// Lazy load children if not yet loaded
|
|
191
262
|
if (!state.loadedPaths.has(path)) {
|
|
192
263
|
get().loadChildren(projectName, path);
|
|
264
|
+
} else {
|
|
265
|
+
// Already loaded (e.g. by prefetch) — stay one level ahead of the user
|
|
266
|
+
const node = findNodeByPath(get().tree, path);
|
|
267
|
+
if (node?.children) queuePrefetchFor(get, projectName, node.children);
|
|
193
268
|
}
|
|
194
269
|
}
|
|
195
270
|
},
|
|
@@ -220,6 +295,7 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
220
295
|
clearSelection: () => set({ selectedFiles: [] }),
|
|
221
296
|
|
|
222
297
|
reset: () => {
|
|
298
|
+
cancelPrefetch();
|
|
223
299
|
// Abort all in-flight requests
|
|
224
300
|
for (const ctrl of get().inflight.values()) ctrl.abort();
|
|
225
301
|
set({
|
|
@@ -245,34 +321,30 @@ export const useFileStore = create<FileStore>((set, get) => ({
|
|
|
245
321
|
},
|
|
246
322
|
}));
|
|
247
323
|
|
|
324
|
+
// --- Expanded-state persistence (per project, localStorage) ---
|
|
325
|
+
|
|
326
|
+
const EXPANDED_KEY_PREFIX = "ppm-explorer-expanded:";
|
|
327
|
+
|
|
328
|
+
export function loadPersistedExpanded(projectName: string): string[] {
|
|
329
|
+
try {
|
|
330
|
+
const raw = localStorage.getItem(EXPANDED_KEY_PREFIX + projectName);
|
|
331
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
332
|
+
return Array.isArray(arr) ? arr.filter((p): p is string => typeof p === "string") : [];
|
|
333
|
+
} catch {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function savePersistedExpanded(projectName: string, expandedPaths: Set<string>): void {
|
|
339
|
+
try {
|
|
340
|
+
// Root ("") is always expanded — no need to persist it
|
|
341
|
+
const paths = [...expandedPaths].filter((p) => p !== "").slice(0, 50);
|
|
342
|
+
localStorage.setItem(EXPANDED_KEY_PREFIX + projectName, JSON.stringify(paths));
|
|
343
|
+
} catch { /* quota/unavailable — skip */ }
|
|
344
|
+
}
|
|
345
|
+
|
|
248
346
|
/** Compute flat visible path list from current tree state (for range selection) */
|
|
249
347
|
export function getVisiblePaths(): string[] {
|
|
250
348
|
const { tree, expandedPaths } = useFileStore.getState();
|
|
251
|
-
|
|
252
|
-
function walk(nodes: FileNode[]) {
|
|
253
|
-
const sorted = [...nodes].sort((a, b) => {
|
|
254
|
-
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
255
|
-
return a.name.localeCompare(b.name);
|
|
256
|
-
});
|
|
257
|
-
for (const n of sorted) {
|
|
258
|
-
// Skip compacted intermediate dirs (matches compact folder rendering)
|
|
259
|
-
let effective = n;
|
|
260
|
-
if (n.type === "directory" && expandedPaths.has(n.path) && n.children) {
|
|
261
|
-
while (
|
|
262
|
-
effective.children &&
|
|
263
|
-
effective.children.length === 1 &&
|
|
264
|
-
effective.children[0]!.type === "directory" &&
|
|
265
|
-
expandedPaths.has(effective.children[0]!.path)
|
|
266
|
-
) {
|
|
267
|
-
effective = effective.children[0]!;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
result.push(effective.path);
|
|
271
|
-
if (effective.type === "directory" && expandedPaths.has(effective.path) && effective.children) {
|
|
272
|
-
walk(effective.children);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
walk(tree);
|
|
277
|
-
return result;
|
|
349
|
+
return visibleNodesOf(tree, expandedPaths).map((n) => n.path);
|
|
278
350
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle prefetch queue for explorer folder children.
|
|
3
|
+
* After a folder loads, children of its non-gitignored subfolders are fetched
|
|
4
|
+
* during browser idle time so the next click renders instantly (hides remote RTT).
|
|
5
|
+
* One level ahead only — prefetched loads never enqueue further prefetches.
|
|
6
|
+
*/
|
|
7
|
+
import type { FileNode } from "./file-store";
|
|
8
|
+
|
|
9
|
+
const MAX_FOLDERS_PER_BATCH = 20;
|
|
10
|
+
const CONCURRENCY = 3;
|
|
11
|
+
|
|
12
|
+
type LoadFn = (path: string) => Promise<void>;
|
|
13
|
+
|
|
14
|
+
let queue: string[] = [];
|
|
15
|
+
let active = 0;
|
|
16
|
+
let generation = 0;
|
|
17
|
+
let loadFn: LoadFn | null = null;
|
|
18
|
+
|
|
19
|
+
const idle: (cb: () => void) => void =
|
|
20
|
+
typeof requestIdleCallback === "function"
|
|
21
|
+
? (cb) => requestIdleCallback(() => cb(), { timeout: 1000 })
|
|
22
|
+
: (cb) => setTimeout(cb, 200);
|
|
23
|
+
|
|
24
|
+
function pump(gen: number): void {
|
|
25
|
+
if (gen !== generation) return;
|
|
26
|
+
while (active < CONCURRENCY && queue.length > 0) {
|
|
27
|
+
const path = queue.shift()!;
|
|
28
|
+
active++;
|
|
29
|
+
loadFn!(path).finally(() => {
|
|
30
|
+
active--;
|
|
31
|
+
if (gen === generation) idle(() => pump(gen));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Queue children of the given nodes for idle prefetch (skips ignored dirs) */
|
|
37
|
+
export function schedulePrefetch(
|
|
38
|
+
children: FileNode[],
|
|
39
|
+
isAlreadyLoadedOrInflight: (path: string) => boolean,
|
|
40
|
+
load: LoadFn,
|
|
41
|
+
): void {
|
|
42
|
+
loadFn = load;
|
|
43
|
+
const dirs = children
|
|
44
|
+
.filter((c) => c.type === "directory" && !c.ignored && !isAlreadyLoadedOrInflight(c.path))
|
|
45
|
+
.slice(0, MAX_FOLDERS_PER_BATCH)
|
|
46
|
+
.map((c) => c.path);
|
|
47
|
+
if (dirs.length === 0) return;
|
|
48
|
+
queue.push(...dirs.filter((p) => !queue.includes(p)));
|
|
49
|
+
const gen = generation;
|
|
50
|
+
idle(() => pump(gen));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Drop all pending prefetches (project switch / store reset) */
|
|
54
|
+
export function cancelPrefetch(): void {
|
|
55
|
+
generation++;
|
|
56
|
+
queue = [];
|
|
57
|
+
}
|