@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.
- package/CHANGELOG.md +13 -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-CQWFrDeG.js} +1 -1
- package/dist/web/assets/{audio-preview-DnSpaaBX.js → audio-preview-Cyb8SLCu.js} +1 -1
- package/dist/web/assets/chat-tab-uBmb2rFB.js +13 -0
- package/dist/web/assets/code-editor-DTqFVL_2.js +10 -0
- package/dist/web/assets/{conflict-editor-LDIdkUJm.js → conflict-editor-BFiY6VyT.js} +3 -3
- package/dist/web/assets/csv-preview-DpeN7hH8.js +2 -0
- package/dist/web/assets/{database-viewer-RkxcBpY3.js → database-viewer-D9OT6V9B.js} +1 -1
- package/dist/web/assets/{diff-viewer-YTRJpdV6.js → diff-viewer-LqH4ARjy.js} +1 -1
- package/dist/web/assets/{docx-preview-B4B4W5LU.js → docx-preview-Dqzlw47y.js} +1 -1
- package/dist/web/assets/{extension-webview-CB8-nHNA.js → extension-webview-DsNuZtJO.js} +1 -1
- package/dist/web/assets/{git-log-panel-OXE-EtxM.js → git-log-panel-mcn-Atil.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CCVPfJtk.js → glide-data-grid-CyZAiAgS.js} +4 -4
- package/dist/web/assets/group-chat-tab-CRkPysZw.js +1 -0
- package/dist/web/assets/{image-preview-CenSD210.js → image-preview-DnNyBpmU.js} +1 -1
- package/dist/web/assets/index-D4KFhwmL.js +50 -0
- package/dist/web/assets/keybindings-store-CtrEuZpU.js +1 -0
- package/dist/web/assets/{markdown-renderer-CyzYLWuu.js → markdown-renderer-BFwR1QYD.js} +2 -2
- package/dist/web/assets/{markdown-renderer-CAFmdvwK.js → markdown-renderer-DDFElf1k.js} +1 -1
- package/dist/web/assets/notification-store-BTo6li6s.js +1 -0
- package/dist/web/assets/{pdf-preview-CsOL51Lb.js → pdf-preview-DDRKbO4z.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Dtt-XLSP.js → postgres-viewer-0UqxqUYu.js} +2 -2
- package/dist/web/assets/{settings-tab-BR9fp2pd.js → settings-tab-0VKOVbt2.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BwquzTWY.js → sql-query-editor-Cf76aKfw.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-YO4IEGzt.js → sqlite-viewer-CO6dK1CU.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-C-WT_FG3.js → system-monitor-tab-BFBwiME3.js} +1 -1
- package/dist/web/assets/{terminal-tab-BqW8reuZ.js → terminal-tab-lu0ZsZmQ.js} +2 -2
- package/dist/web/assets/{tool-cards-DtF80k3f.js → tool-cards-CMQowijv.js} +3 -3
- package/dist/web/assets/{use-monaco-theme-CF_57XHm.js → use-monaco-theme-DU3LWaPu.js} +1 -1
- package/dist/web/assets/{video-preview-OoDYJ8U4.js → video-preview-CHUzaq4s.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/index.ts +2 -0
- package/src/server/middleware/gzip-json.ts +36 -0
- package/src/server/routes/files.ts +24 -0
- package/src/server/routes/static.ts +24 -3
- package/src/services/file-list-index.service.ts +34 -3
- package/src/services/file.service.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/hooks/use-chat.ts +13 -2
- 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,
|
|
@@ -168,6 +168,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
168
168
|
* replay, cleared on turn done. On reload of an unfinished turn the REST history
|
|
169
169
|
* ALSO contains this turn, so the REST merge trims it to avoid a double render. */
|
|
170
170
|
const replayTurnUserMsgRef = useRef<string | null>(null);
|
|
171
|
+
/** When the last full-transcript fetch completed — guards redundant idle refetches */
|
|
172
|
+
const historyLoadedAtRef = useRef(0);
|
|
171
173
|
const sessionIdRef = useRef(sessionId);
|
|
172
174
|
sessionIdRef.current = sessionId;
|
|
173
175
|
// Mirror of `messages` for synchronous reads (e.g. snapshotting the previous
|
|
@@ -648,6 +650,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
648
650
|
setConnectedSessionId((data as any).sessionId ?? sessionIdRef.current);
|
|
649
651
|
const state = data as any;
|
|
650
652
|
const p = state.phase as SessionPhase;
|
|
653
|
+
const wasIdle = phaseRef.current === "idle";
|
|
651
654
|
setPhase(p);
|
|
652
655
|
phaseRef.current = p;
|
|
653
656
|
if (state.sessionTitle) setSessionTitle(state.sessionTitle);
|
|
@@ -682,9 +685,14 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
682
685
|
// Sync compact indicator from authoritative server state (covers reconnect).
|
|
683
686
|
// state.compactStatus is "compacting" | null — treat undefined as null for back-compat.
|
|
684
687
|
setCompactStatus(state.compactStatus === "compacting" ? "compacting" : null);
|
|
685
|
-
// If idle, refetch history (completed turns) and hide overlay
|
|
688
|
+
// If idle, refetch history (completed turns) and hide overlay.
|
|
689
|
+
// Skip when nothing could have changed: the phase was already idle locally
|
|
690
|
+
// and the full transcript finished loading moments ago — on boot the WS
|
|
691
|
+
// connects right after the mount fetch and this refetch would re-download
|
|
692
|
+
// the entire history (and remount every transcript image) for no reason.
|
|
686
693
|
if (p === "idle") {
|
|
687
|
-
|
|
694
|
+
const historyFresh = Date.now() - historyLoadedAtRef.current < 5000;
|
|
695
|
+
if (!(wasIdle && historyFresh)) refetchRef.current?.();
|
|
688
696
|
setIsReconnecting(false);
|
|
689
697
|
}
|
|
690
698
|
// If streaming, turn_events message will follow
|
|
@@ -809,6 +817,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
809
817
|
streamingContentRef.current = "";
|
|
810
818
|
streamingEventsRef.current = [];
|
|
811
819
|
replayTurnUserMsgRef.current = null;
|
|
820
|
+
historyLoadedAtRef.current = 0;
|
|
812
821
|
bashOutputRef.current.clear();
|
|
813
822
|
if (syncRafRef.current) { clearTimeout(syncRafRef.current); syncRafRef.current = 0; }
|
|
814
823
|
setIsConnected(false);
|
|
@@ -860,6 +869,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
860
869
|
const pending = prev.filter((m) => !staleIds.has(m.id));
|
|
861
870
|
return [...history, ...pending];
|
|
862
871
|
});
|
|
872
|
+
historyLoadedAtRef.current = Date.now();
|
|
863
873
|
})
|
|
864
874
|
.catch(() => {
|
|
865
875
|
if (!cancelled) setMessages((prev) => prev.filter((m) => !staleIds.has(m.id)));
|
|
@@ -1096,6 +1106,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
1096
1106
|
streamingContentRef.current = "";
|
|
1097
1107
|
streamingEventsRef.current = [];
|
|
1098
1108
|
}
|
|
1109
|
+
historyLoadedAtRef.current = Date.now();
|
|
1099
1110
|
})
|
|
1100
1111
|
.catch(() => {});
|
|
1101
1112
|
}, [sessionId, providerId, projectName]);
|
|
@@ -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
|
}
|