@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-bGSN2dFv.js → ai-resource-editor-BFpX8O8g.js} +1 -1
  5. package/dist/web/assets/{audio-preview-DnSpaaBX.js → audio-preview-CCszWhE7.js} +1 -1
  6. package/dist/web/assets/chat-tab-rSC30FXc.js +13 -0
  7. package/dist/web/assets/code-editor-Br-uzog8.js +10 -0
  8. package/dist/web/assets/{conflict-editor-LDIdkUJm.js → conflict-editor-JlKPk6Pr.js} +3 -3
  9. package/dist/web/assets/csv-preview-5RP4HsC0.js +2 -0
  10. package/dist/web/assets/{database-viewer-RkxcBpY3.js → database-viewer-DSTjX9zV.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-YTRJpdV6.js → diff-viewer-Dm715N42.js} +1 -1
  12. package/dist/web/assets/{docx-preview-B4B4W5LU.js → docx-preview-QeS2QgmK.js} +1 -1
  13. package/dist/web/assets/{extension-webview-CB8-nHNA.js → extension-webview-mwQAJuOv.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-OXE-EtxM.js → git-log-panel-Bs8PPvDI.js} +1 -1
  15. package/dist/web/assets/{glide-data-grid-CCVPfJtk.js → glide-data-grid-DYQLp-gP.js} +4 -4
  16. package/dist/web/assets/group-chat-tab--0hGwVFa.js +1 -0
  17. package/dist/web/assets/{image-preview-CenSD210.js → image-preview-DahiriA8.js} +1 -1
  18. package/dist/web/assets/index-BOhGyv5C.js +50 -0
  19. package/dist/web/assets/keybindings-store-pi3VOJGw.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-CAFmdvwK.js → markdown-renderer-C8PH2HyN.js} +1 -1
  21. package/dist/web/assets/{markdown-renderer-CyzYLWuu.js → markdown-renderer-CSeQnHB5.js} +2 -2
  22. package/dist/web/assets/notification-store-D6QMHrX0.js +1 -0
  23. package/dist/web/assets/{pdf-preview-CsOL51Lb.js → pdf-preview-ChAi-S2U.js} +1 -1
  24. package/dist/web/assets/{postgres-viewer-Dtt-XLSP.js → postgres-viewer-MIPyKZ_B.js} +2 -2
  25. package/dist/web/assets/{settings-tab-BR9fp2pd.js → settings-tab-bzAngl0_.js} +1 -1
  26. package/dist/web/assets/{sql-query-editor-BwquzTWY.js → sql-query-editor-CG8tbxLG.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-YO4IEGzt.js → sqlite-viewer-DxqQyT9P.js} +1 -1
  28. package/dist/web/assets/{system-monitor-tab-C-WT_FG3.js → system-monitor-tab-z708TL13.js} +1 -1
  29. package/dist/web/assets/{terminal-tab-BqW8reuZ.js → terminal-tab-EwgucrX3.js} +2 -2
  30. package/dist/web/assets/{tool-cards-DtF80k3f.js → tool-cards-4hL8H_UL.js} +3 -3
  31. package/dist/web/assets/{use-monaco-theme-CF_57XHm.js → use-monaco-theme-DT_GPTTP.js} +1 -1
  32. package/dist/web/assets/{video-preview-OoDYJ8U4.js → video-preview-r0pg-GlA.js} +1 -1
  33. package/dist/web/index.html +1 -1
  34. package/dist/web/sw.js +1 -1
  35. package/package.json +1 -1
  36. package/src/server/routes/files.ts +24 -0
  37. package/src/services/file-list-index.service.ts +34 -3
  38. package/src/services/file.service.ts +6 -0
  39. package/src/services/windows-zombie-port-reaper.ts +6 -0
  40. package/src/web/components/explorer/file-tree.tsx +115 -44
  41. package/src/web/components/explorer/flatten-visible-tree.ts +102 -0
  42. package/src/web/components/explorer/tree-node.tsx +82 -166
  43. package/src/web/components/explorer/use-tree-keyboard-nav.ts +3 -30
  44. package/src/web/stores/file-store.ts +101 -29
  45. package/src/web/stores/file-tree-prefetch.ts +57 -0
  46. package/dist/web/assets/chat-tab-Cvuo7XWm.js +0 -13
  47. package/dist/web/assets/code-editor-BvwCPxaD.js +0 -10
  48. package/dist/web/assets/csv-preview-47tAvhIq.js +0 -2
  49. package/dist/web/assets/group-chat-tab-D5rkf_zG.js +0 -1
  50. package/dist/web/assets/index-bQy6gwMT.js +0 -50
  51. package/dist/web/assets/keybindings-store-Bk2X4njD.js +0 -1
  52. package/dist/web/assets/notification-store-C9MS_FlD.js +0 -1
@@ -1,5 +1,6 @@
1
1
  /**
2
- * TreeNode component — renders a single file/folder row in the explorer tree.
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, type InlineAction } from "@/stores/file-store";
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
- export interface TreeNodeProps {
35
- node: FileNode;
36
- depth: number;
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 TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
44
- const { expandedPaths, loadedPaths, inflight, toggleExpand, selectedFiles, toggleFileSelect, inlineAction, clearInlineAction, clipboard, focusedPath, setFocusedPath } = useFileStore(
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(node.path) && inflight.has(node.path);
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(node.path);
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
- /** Accept both external file drops and internal tree moves on directories */
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(node.path, e.dataTransfer.files);
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 === node.path || node.path.startsWith(`${sourcePath}/`)) return;
173
+ if (sourcePath === dropTargetDir || dropTargetDir.startsWith(`${sourcePath}/`)) return;
172
174
  // Prevent no-op (already in this folder)
173
- const sourceParent = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "";
174
- if (sourceParent === node.path) return;
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 = node.path ? `${node.path}/${sourceName}` : sourceName;
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, node.path);
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={isDir ? handleNodeDragEnter : undefined}
225
- onDragLeave={isDir ? handleNodeDragLeave : undefined}
226
- onDragOver={isDir ? handleNodeDragOver : undefined}
227
- onDrop={isDir ? handleNodeDrop : undefined}
199
+ onDragEnter={handleNodeDragEnter}
200
+ onDragLeave={handleNodeDragLeave}
201
+ onDragOver={handleNodeDragOver}
202
+ onDrop={handleNodeDrop}
228
203
  >
229
- {isRenaming ? (
230
- <InlineTreeInput
231
- defaultValue={node.name}
232
- placeholder={node.name}
233
- depth={depth}
234
- icon={isDir ? "folder" : "file"}
235
- onConfirm={async (newName) => {
236
- if (newName === node.name) { clearInlineAction(); return; }
237
- const parentPath = node.path.includes("/")
238
- ? node.path.slice(0, node.path.lastIndexOf("/"))
239
- : "";
240
- const newPath = parentPath ? `${parentPath}/${newName}` : newName;
241
- await api.post(`${projectUrl(projectName)}/files/rename`, {
242
- oldPath: node.path,
243
- newPath,
244
- });
245
- clearInlineAction();
246
- const store = useFileStore.getState();
247
- store.invalidateIndex();
248
- store.loadIndex(projectName);
249
- store.invalidateFolder(projectName, parentPath);
250
- }}
251
- onCancel={clearInlineAction}
252
- />
253
- ) : (
254
- <ContextMenu>
255
- <ContextMenuTrigger asChild>
256
- <button
257
- ref={rowRef}
258
- draggable
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
- "flex items-center w-full gap-1.5 px-2 py-1 rounded-[var(--rad-sm)] text-[13px]",
263
- "min-h-[32px] md:min-h-[26px] hover:bg-surface-elevated transition-colors text-left",
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
- style={{ paddingLeft: `${depth * 16 + 8}px` }}
271
- >
272
- {isDir ? (
273
- isLoadingChildren ? (
274
- <Loader2 className="size-3.5 shrink-0 text-text-subtle animate-spin" />
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
- <FileIcon
284
- className={cn(
285
- "size-4 shrink-0",
286
- fileIconColor ?? "text-text-secondary",
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
- {gitStatus && !isDir && (
298
- <span className={cn("text-[10px] ml-auto shrink-0 font-mono", gitColor)}>
299
- {gitStatus}
300
- </span>
301
- )}
302
- </button>
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 { useFileStore, type FileNode } from "@/stores/file-store";
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
- const result: string[] = [];
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
+ }