@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
|
@@ -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 {
|
|
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 {
|
|
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 +
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
<
|
|
391
|
-
<div className="
|
|
392
|
-
{
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
</
|
|
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
|
+
}
|