@hienlh/ppm 0.17.41 → 0.17.43

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 (55) hide show
  1. package/CHANGELOG.md +13 -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-CQWFrDeG.js} +1 -1
  5. package/dist/web/assets/{audio-preview-DnSpaaBX.js → audio-preview-Cyb8SLCu.js} +1 -1
  6. package/dist/web/assets/chat-tab-uBmb2rFB.js +13 -0
  7. package/dist/web/assets/code-editor-DTqFVL_2.js +10 -0
  8. package/dist/web/assets/{conflict-editor-LDIdkUJm.js → conflict-editor-BFiY6VyT.js} +3 -3
  9. package/dist/web/assets/csv-preview-DpeN7hH8.js +2 -0
  10. package/dist/web/assets/{database-viewer-RkxcBpY3.js → database-viewer-D9OT6V9B.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-YTRJpdV6.js → diff-viewer-LqH4ARjy.js} +1 -1
  12. package/dist/web/assets/{docx-preview-B4B4W5LU.js → docx-preview-Dqzlw47y.js} +1 -1
  13. package/dist/web/assets/{extension-webview-CB8-nHNA.js → extension-webview-DsNuZtJO.js} +1 -1
  14. package/dist/web/assets/{git-log-panel-OXE-EtxM.js → git-log-panel-mcn-Atil.js} +1 -1
  15. package/dist/web/assets/{glide-data-grid-CCVPfJtk.js → glide-data-grid-CyZAiAgS.js} +4 -4
  16. package/dist/web/assets/group-chat-tab-CRkPysZw.js +1 -0
  17. package/dist/web/assets/{image-preview-CenSD210.js → image-preview-DnNyBpmU.js} +1 -1
  18. package/dist/web/assets/index-D4KFhwmL.js +50 -0
  19. package/dist/web/assets/keybindings-store-CtrEuZpU.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-CyzYLWuu.js → markdown-renderer-BFwR1QYD.js} +2 -2
  21. package/dist/web/assets/{markdown-renderer-CAFmdvwK.js → markdown-renderer-DDFElf1k.js} +1 -1
  22. package/dist/web/assets/notification-store-BTo6li6s.js +1 -0
  23. package/dist/web/assets/{pdf-preview-CsOL51Lb.js → pdf-preview-DDRKbO4z.js} +1 -1
  24. package/dist/web/assets/{postgres-viewer-Dtt-XLSP.js → postgres-viewer-0UqxqUYu.js} +2 -2
  25. package/dist/web/assets/{settings-tab-BR9fp2pd.js → settings-tab-0VKOVbt2.js} +1 -1
  26. package/dist/web/assets/{sql-query-editor-BwquzTWY.js → sql-query-editor-Cf76aKfw.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-YO4IEGzt.js → sqlite-viewer-CO6dK1CU.js} +1 -1
  28. package/dist/web/assets/{system-monitor-tab-C-WT_FG3.js → system-monitor-tab-BFBwiME3.js} +1 -1
  29. package/dist/web/assets/{terminal-tab-BqW8reuZ.js → terminal-tab-lu0ZsZmQ.js} +2 -2
  30. package/dist/web/assets/{tool-cards-DtF80k3f.js → tool-cards-CMQowijv.js} +3 -3
  31. package/dist/web/assets/{use-monaco-theme-CF_57XHm.js → use-monaco-theme-DU3LWaPu.js} +1 -1
  32. package/dist/web/assets/{video-preview-OoDYJ8U4.js → video-preview-CHUzaq4s.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/index.ts +2 -0
  37. package/src/server/middleware/gzip-json.ts +36 -0
  38. package/src/server/routes/files.ts +24 -0
  39. package/src/server/routes/static.ts +24 -3
  40. package/src/services/file-list-index.service.ts +34 -3
  41. package/src/services/file.service.ts +6 -0
  42. package/src/web/components/explorer/file-tree.tsx +115 -44
  43. package/src/web/components/explorer/flatten-visible-tree.ts +102 -0
  44. package/src/web/components/explorer/tree-node.tsx +82 -166
  45. package/src/web/components/explorer/use-tree-keyboard-nav.ts +3 -30
  46. package/src/web/hooks/use-chat.ts +13 -2
  47. package/src/web/stores/file-store.ts +101 -29
  48. package/src/web/stores/file-tree-prefetch.ts +57 -0
  49. package/dist/web/assets/chat-tab-Cvuo7XWm.js +0 -13
  50. package/dist/web/assets/code-editor-BvwCPxaD.js +0 -10
  51. package/dist/web/assets/csv-preview-47tAvhIq.js +0 -2
  52. package/dist/web/assets/group-chat-tab-D5rkf_zG.js +0 -1
  53. package/dist/web/assets/index-bQy6gwMT.js +0 -50
  54. package/dist/web/assets/keybindings-store-Bk2X4njD.js +0 -1
  55. package/dist/web/assets/notification-store-C9MS_FlD.js +0 -1
@@ -59,20 +59,51 @@ function assertWithinProject(relPath: string, projectPath: string): void {
59
59
  // listDir — single directory level
60
60
  // ---------------------------------------------------------------------------
61
61
 
62
+ /** Per-request listing context: filter + gitignore loaded once, reused across paths */
63
+ interface ListContext {
64
+ filter: ReturnType<typeof resolveFilter>;
65
+ ig: Ignore | null;
66
+ }
67
+
68
+ function loadListContext(projectPath: string): ListContext {
69
+ const filter = resolveFilter(projectPath);
70
+ const ig = filter.useIgnoreFiles ? loadGitignore(projectPath) : null;
71
+ return { filter, ig };
72
+ }
73
+
62
74
  /**
63
75
  * List one directory level for lazy-load file tree.
64
76
  * Applies filesExclude patterns from resolved filter.
65
77
  * Marks entries as isIgnored based on .gitignore (informational — still listed).
66
78
  */
67
79
  export function listDir(projectPath: string, relPath: string): FileDirEntry[] {
80
+ return listDirWithContext(projectPath, relPath, loadListContext(projectPath));
81
+ }
82
+
83
+ /**
84
+ * List multiple directory levels in one call (single filter/gitignore load).
85
+ * Per-path failures land in `error` without failing the whole batch.
86
+ */
87
+ export function listDirBatch(
88
+ projectPath: string,
89
+ paths: string[],
90
+ ): { path: string; entries?: FileDirEntry[]; error?: string }[] {
91
+ const ctx = loadListContext(projectPath);
92
+ return paths.map((p) => {
93
+ try {
94
+ return { path: p, entries: listDirWithContext(projectPath, p, ctx) };
95
+ } catch (e) {
96
+ return { path: p, error: (e as Error).message };
97
+ }
98
+ });
99
+ }
100
+
101
+ function listDirWithContext(projectPath: string, relPath: string, { filter, ig }: ListContext): FileDirEntry[] {
68
102
  if (relPath) assertWithinProject(relPath, projectPath);
69
103
 
70
104
  const absDir = relPath ? resolve(projectPath, relPath) : projectPath;
71
105
  if (!existsSync(absDir)) throw new NotFoundError(`Directory not found: ${relPath || "/"}`);
72
106
 
73
- const filter = resolveFilter(projectPath);
74
- const ig = filter.useIgnoreFiles ? loadGitignore(projectPath) : null;
75
-
76
107
  let rawEntries;
77
108
  try { rawEntries = readdirSync(absDir, { withFileTypes: true }); }
78
109
  catch { return []; }
@@ -15,6 +15,7 @@ import ignore, { type Ignore } from "ignore";
15
15
  import type { FileNode, FileEntry, FileDirEntry } from "../types/project.ts";
16
16
  import {
17
17
  listDir as listDirImpl,
18
+ listDirBatch as listDirBatchImpl,
18
19
  buildIndex as buildIndexImpl,
19
20
  invalidateIndexCache,
20
21
  clearIndexCache,
@@ -277,6 +278,11 @@ class FileService {
277
278
  return listDirImpl(projectPath, relPath);
278
279
  }
279
280
 
281
+ /** List multiple directories in one call (filter/gitignore loaded once) */
282
+ listDirBatch(projectPath: string, paths: string[]): { path: string; entries?: FileDirEntry[]; error?: string }[] {
283
+ return listDirBatchImpl(projectPath, paths);
284
+ }
285
+
280
286
  /**
281
287
  * Build flat file index for palette/search (delegates to file-list-index.service).
282
288
  * Cached per project; invalidated on file change via invalidateIndexCache().
@@ -2,7 +2,8 @@
2
2
  * FileTree — the main file explorer container.
3
3
  * Renders toolbar, tree nodes via TreeNode, root-level drag/drop, and file actions.
4
4
  */
5
- import { useEffect, useCallback, useState, useRef } from "react";
5
+ import { useEffect, useCallback, useState, useRef, useMemo } from "react";
6
+ import { useVirtualizer } from "@tanstack/react-virtual";
6
7
  import {
7
8
  FilePlus,
8
9
  FolderPlus,
@@ -15,14 +16,14 @@ import {
15
16
  import { SidebarHeader } from "@/components/ui/sidebar-header";
16
17
  import { copyToClipboard } from "@/lib/clipboard";
17
18
  import { useShallow } from "zustand/react/shallow";
18
- import { useFileStore, type FileNode } from "@/stores/file-store";
19
+ import { useFileStore, loadPersistedExpanded, savePersistedExpanded, type FileNode } from "@/stores/file-store";
19
20
  import { useProjectStore } from "@/stores/project-store";
20
21
  import { useTabStore } from "@/stores/tab-store";
21
22
  import { useCompareStore } from "@/stores/compare-store";
22
23
  import { openCompareTab } from "@/lib/open-compare-tab";
23
24
  import { toast } from "sonner";
24
25
  import { cn, basename } from "@/lib/utils";
25
- import { ScrollArea } from "@/components/ui/scroll-area";
26
+ import { useIsMobile } from "@/hooks/use-is-mobile";
26
27
  import {
27
28
  ContextMenu,
28
29
  ContextMenuContent,
@@ -31,8 +32,9 @@ import {
31
32
  ContextMenuTrigger,
32
33
  } from "@/components/ui/adaptive-context-menu";
33
34
  import { FileActions } from "./file-actions";
34
- import { TreeNode } from "./tree-node";
35
+ import { TreeRow } from "./tree-node";
35
36
  import { InlineTreeInput } from "./inline-tree-input";
37
+ import { flattenVisibleTree, type InputRow } from "./flatten-visible-tree";
36
38
  import { downloadFile, downloadFolder } from "@/lib/file-download";
37
39
  import { api, projectUrl } from "@/lib/api-client";
38
40
  import { useFileUploadDrag } from "./use-file-upload-drag";
@@ -166,17 +168,29 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
166
168
  onAction: handleAction,
167
169
  });
168
170
 
169
- // On project switch: reset + load root + load index + auto-expand root
171
+ // On project switch: reset + restore expanded state + load all visible folders in ONE batch
170
172
  useEffect(() => {
171
173
  if (!activeProject) return;
172
174
  reset();
173
175
  const name = activeProject.name;
174
- loadRoot(name).then(() => {
175
- useFileStore.getState().setExpanded("", true);
176
- });
176
+ const persisted = loadPersistedExpanded(name);
177
+ useFileStore.setState({ expandedPaths: new Set(["", ...persisted]) });
178
+ if (persisted.length > 0) {
179
+ useFileStore.getState().loadPathsBatch(name, ["", ...persisted]);
180
+ } else {
181
+ loadRoot(name);
182
+ }
177
183
  loadIndex(name);
178
184
  }, [activeProject?.name]); // eslint-disable-line react-hooks/exhaustive-deps
179
185
 
186
+ // Persist expanded state per project. Reads LIVE store state (not the captured
187
+ // render value): on project switch the captured set still belongs to the previous
188
+ // project, while live state was already reset+restored by the mount effect above.
189
+ useEffect(() => {
190
+ if (!activeProject) return;
191
+ savePersistedExpanded(activeProject.name, useFileStore.getState().expandedPaths);
192
+ }, [expandedPaths, activeProject?.name]); // eslint-disable-line react-hooks/exhaustive-deps
193
+
180
194
  // Handle WS file:changed → invalidate folder + index
181
195
  useEffect(() => {
182
196
  if (!activeProject) return;
@@ -212,6 +226,56 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
212
226
  handleRootDragEnter, handleRootDragLeave, handleRootDragOver, handleRootDrop,
213
227
  } = useFileUploadDrag({ projectName: activeProject?.name, setExpanded });
214
228
 
229
+ // Virtualized flat rows: only visible rows are mounted (large dirs stay cheap)
230
+ const rows = useMemo(
231
+ () => flattenVisibleTree(tree, expandedPaths, inlineAction),
232
+ [tree, expandedPaths, inlineAction],
233
+ );
234
+ const scrollRef = useRef<HTMLDivElement>(null);
235
+ const isMobile = useIsMobile();
236
+ const rowVirtualizer = useVirtualizer({
237
+ count: rows.length,
238
+ getScrollElement: () => scrollRef.current,
239
+ estimateSize: () => (isMobile ? 32 : 26),
240
+ overscan: 10,
241
+ paddingStart: 4,
242
+ paddingEnd: 4,
243
+ });
244
+
245
+ // Keep focused row in view even when it's not mounted (offscreen)
246
+ useEffect(() => {
247
+ if (focusedPath == null) return;
248
+ const idx = rows.findIndex(
249
+ (r) => r.kind === "node" && (r.node.path === focusedPath || r.effectiveNode.path === focusedPath),
250
+ );
251
+ if (idx >= 0) rowVirtualizer.scrollToIndex(idx, { align: "auto" });
252
+ }, [focusedPath]); // eslint-disable-line react-hooks/exhaustive-deps
253
+
254
+ /** Confirm inline create/rename input rows */
255
+ const handleInlineConfirm = useCallback(async (row: InputRow, value: string) => {
256
+ const projectName = activeProject!.name;
257
+ const store = useFileStore.getState();
258
+ if (row.inline.type === "rename") {
259
+ const node = row.inline.existingNode!;
260
+ if (value === node.name) { clearInlineAction(); return; }
261
+ const parentPath = node.path.includes("/") ? node.path.slice(0, node.path.lastIndexOf("/")) : "";
262
+ const newPath = parentPath ? `${parentPath}/${value}` : value;
263
+ await api.post(`${projectUrl(projectName)}/files/rename`, { oldPath: node.path, newPath });
264
+ clearInlineAction();
265
+ store.invalidateIndex();
266
+ store.loadIndex(projectName);
267
+ store.invalidateFolder(projectName, parentPath);
268
+ } else {
269
+ const type = row.inline.type === "new-file" ? "file" : "directory";
270
+ const fullPath = row.targetPath ? `${row.targetPath}/${value}` : value;
271
+ await api.post(`${projectUrl(projectName)}/files/create`, { path: fullPath, type });
272
+ clearInlineAction();
273
+ store.invalidateIndex();
274
+ store.loadIndex(projectName);
275
+ store.invalidateFolder(projectName, row.targetPath);
276
+ }
277
+ }, [activeProject, clearInlineAction]);
278
+
215
279
  async function handleAction(action: string, node: FileNode) {
216
280
  if (action === "toggle-expand" && node.type === "directory") {
217
281
  toggleExpand(activeProject!.name, node.path);
@@ -348,11 +412,6 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
348
412
  );
349
413
  }
350
414
 
351
- const sorted = [...tree].sort((a, b) => {
352
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
353
- return a.name.localeCompare(b.name);
354
- });
355
-
356
415
  const toolbarBtnClass = "flex size-6 items-center justify-center rounded text-text-subtle hover:bg-surface-elevated hover:text-foreground";
357
416
 
358
417
  return (
@@ -384,42 +443,54 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
384
443
  </button>
385
444
  </SidebarHeader>
386
445
 
387
- {/* File tree with blank-area context menu */}
446
+ {/* File tree with blank-area context menu — virtualized flat rows */}
388
447
  <ContextMenu>
389
448
  <ContextMenuTrigger asChild>
390
- <ScrollArea className="flex-1">
391
- <div className="py-1">
392
- {inlineAction && inlineAction.parentPath === "" && inlineAction.type !== "rename" && (
393
- <InlineTreeInput
394
- defaultValue=""
395
- placeholder={inlineAction.type === "new-file" ? "filename.ts" : "folder-name"}
396
- depth={0}
397
- icon={inlineAction.type === "new-file" ? "file" : "folder"}
398
- onConfirm={async (name) => {
399
- const type = inlineAction.type === "new-file" ? "file" : "directory";
400
- await api.post(`${projectUrl(activeProject!.name)}/files/create`, { path: name, type });
401
- clearInlineAction();
402
- reloadTree();
403
- }}
404
- onCancel={clearInlineAction}
405
- />
406
- )}
407
- {sorted.map((node) => (
408
- <TreeNode
409
- key={node.path}
410
- node={node}
411
- depth={0}
412
- projectName={activeProject.name}
413
- onAction={handleAction}
414
- onFileDrop={uploadFiles}
415
- onFileOpen={onFileOpen}
416
- />
417
- ))}
418
- {sorted.length === 0 && (
449
+ <div ref={scrollRef} className="flex-1 overflow-y-auto">
450
+ <div className="relative w-full" style={{ height: rowVirtualizer.getTotalSize() }}>
451
+ {rowVirtualizer.getVirtualItems().map((vi) => {
452
+ const row = rows[vi.index]!;
453
+ return (
454
+ <div
455
+ key={row.kind === "node" ? row.node.path : `input:${row.targetPath}:${row.inline.type}`}
456
+ data-index={vi.index}
457
+ ref={rowVirtualizer.measureElement}
458
+ className="absolute left-0 top-0 w-full"
459
+ style={{ transform: `translateY(${vi.start}px)` }}
460
+ >
461
+ {row.kind === "input" ? (
462
+ <InlineTreeInput
463
+ defaultValue={row.inline.type === "rename" ? row.inline.existingNode!.name : ""}
464
+ placeholder={
465
+ row.inline.type === "rename" ? row.inline.existingNode!.name
466
+ : row.inline.type === "new-file" ? "filename.ts" : "folder-name"
467
+ }
468
+ depth={row.depth}
469
+ icon={
470
+ row.inline.type === "rename"
471
+ ? (row.inline.existingNode!.type === "directory" ? "folder" : "file")
472
+ : (row.inline.type === "new-file" ? "file" : "folder")
473
+ }
474
+ onConfirm={(value) => handleInlineConfirm(row, value)}
475
+ onCancel={clearInlineAction}
476
+ />
477
+ ) : (
478
+ <TreeRow
479
+ row={row}
480
+ projectName={activeProject.name}
481
+ onAction={handleAction}
482
+ onFileDrop={uploadFiles}
483
+ onFileOpen={onFileOpen}
484
+ />
485
+ )}
486
+ </div>
487
+ );
488
+ })}
489
+ {rows.length === 0 && (
419
490
  <p className="p-3 text-xs text-text-subtle">Empty project.</p>
420
491
  )}
421
492
  </div>
422
- </ScrollArea>
493
+ </div>
423
494
  </ContextMenuTrigger>
424
495
  <ContextMenuContent>
425
496
  <ContextMenuItem onClick={() => handleAction("new-file", ROOT_NODE)}>
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Single source of truth for the explorer's visible-row computation.
3
+ * Flattens the lazy tree into ordered rows respecting expand state,
4
+ * dir-first sorting, compact single-child folder chains, and inline
5
+ * create/rename input rows. Consumed by the virtualized renderer,
6
+ * keyboard navigation, and range selection.
7
+ */
8
+ import type { FileNode, InlineAction } from "@/stores/file-store";
9
+
10
+ export interface NodeRow {
11
+ kind: "node";
12
+ /** Original node (row identity, drag source, context menu target) */
13
+ node: FileNode;
14
+ /** Terminal node of a compacted single-child dir chain (equals node when not compacted) */
15
+ effectiveNode: FileNode;
16
+ /** Display label, e.g. "a/b/c" for compacted chains */
17
+ displayName: string;
18
+ depth: number;
19
+ }
20
+
21
+ export interface InputRow {
22
+ kind: "input";
23
+ inline: InlineAction;
24
+ depth: number;
25
+ /** Directory the created entry lands in (effective path for compacted chains) */
26
+ targetPath: string;
27
+ }
28
+
29
+ export type FlatRow = NodeRow | InputRow;
30
+
31
+ function sortNodes(nodes: FileNode[]): FileNode[] {
32
+ return [...nodes].sort((a, b) => {
33
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
34
+ return a.name.localeCompare(b.name);
35
+ });
36
+ }
37
+
38
+ /** Follow expanded single-child dir chains; returns terminal node + joined label */
39
+ function compactChain(node: FileNode, expandedPaths: Set<string>): { effective: FileNode; displayName: string } {
40
+ let effective = node;
41
+ let displayName = node.name;
42
+ if (node.type === "directory" && expandedPaths.has(node.path) && node.children) {
43
+ while (
44
+ effective.children &&
45
+ effective.children.length === 1 &&
46
+ effective.children[0]!.type === "directory" &&
47
+ expandedPaths.has(effective.children[0]!.path)
48
+ ) {
49
+ effective = effective.children[0]!;
50
+ displayName += `/${effective.name}`;
51
+ }
52
+ }
53
+ return { effective, displayName };
54
+ }
55
+
56
+ export function flattenVisibleTree(
57
+ tree: FileNode[],
58
+ expandedPaths: Set<string>,
59
+ inlineAction: InlineAction | null = null,
60
+ ): FlatRow[] {
61
+ const rows: FlatRow[] = [];
62
+ const isCreate = inlineAction != null && inlineAction.type !== "rename";
63
+ const renamePath = inlineAction?.type === "rename" ? inlineAction.existingNode?.path : undefined;
64
+
65
+ // Root-level create input appears before all rows
66
+ if (isCreate && inlineAction!.parentPath === "") {
67
+ rows.push({ kind: "input", inline: inlineAction!, depth: 0, targetPath: "" });
68
+ }
69
+
70
+ function walk(nodes: FileNode[], depth: number) {
71
+ for (const n of sortNodes(nodes)) {
72
+ const { effective, displayName } = compactChain(n, expandedPaths);
73
+
74
+ if (renamePath != null && renamePath === n.path) {
75
+ rows.push({ kind: "input", inline: inlineAction!, depth, targetPath: n.path });
76
+ } else {
77
+ rows.push({ kind: "node", node: n, effectiveNode: effective, displayName, depth });
78
+ }
79
+
80
+ const expanded = effective.type === "directory" && expandedPaths.has(effective.path);
81
+ // Create input pinned under its parent dir, before children
82
+ if (
83
+ isCreate && expanded &&
84
+ (inlineAction!.parentPath === n.path || inlineAction!.parentPath === effective.path)
85
+ ) {
86
+ rows.push({ kind: "input", inline: inlineAction!, depth: depth + 1, targetPath: effective.path });
87
+ }
88
+ if (expanded && effective.children) {
89
+ walk(effective.children, depth + 1);
90
+ }
91
+ }
92
+ }
93
+ walk(tree, 0);
94
+ return rows;
95
+ }
96
+
97
+ /** Flat visible effective-node list (keyboard nav, range selection) */
98
+ export function visibleNodesOf(tree: FileNode[], expandedPaths: Set<string>): FileNode[] {
99
+ return flattenVisibleTree(tree, expandedPaths)
100
+ .filter((r): r is NodeRow => r.kind === "node")
101
+ .map((r) => r.effectiveNode);
102
+ }