@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
|
@@ -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 {
|
|
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
|
+
}
|