@noya-app/noya-file-explorer 0.0.30 → 0.0.32
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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +22 -0
- package/dist/index.css +84 -11
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +731 -211
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +759 -212
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/GitFileExplorer.tsx +432 -0
- package/src/git/gitFileTree.ts +110 -0
- package/src/git/useGitFileOperations.ts +184 -0
- package/src/git/useGitFileTree.ts +91 -0
- package/src/index.ts +1 -0
- package/src/utils/handleFileDrop.ts +2 -84
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noya-app/noya-file-explorer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"module": "./dist/index.mjs",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"dev": "npm run build:main -- --watch"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@noya-app/noya-designsystem": "0.1.
|
|
23
|
-
"@noya-app/noya-icons": "0.1.
|
|
24
|
-
"@noya-app/noya-multiplayer-react": "0.1.
|
|
22
|
+
"@noya-app/noya-designsystem": "0.1.77",
|
|
23
|
+
"@noya-app/noya-icons": "0.1.17",
|
|
24
|
+
"@noya-app/noya-multiplayer-react": "0.1.78",
|
|
25
25
|
"@noya-app/noya-keymap": "0.1.4",
|
|
26
|
-
"@noya-app/react-utils": "0.1.
|
|
26
|
+
"@noya-app/react-utils": "0.1.29",
|
|
27
27
|
"@noya-app/noya-utils": "0.1.9",
|
|
28
|
-
"@noya-app/noya-schemas": "0.1.
|
|
28
|
+
"@noya-app/noya-schemas": "0.1.9",
|
|
29
29
|
"imfs": "^0.1.0",
|
|
30
30
|
"browser-fs-access": "0.35.0"
|
|
31
31
|
},
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
CollectionRef,
|
|
6
|
+
createSectionedMenu,
|
|
7
|
+
cx,
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
IconButton,
|
|
10
|
+
List,
|
|
11
|
+
MediaThumbnail,
|
|
12
|
+
Section,
|
|
13
|
+
Small,
|
|
14
|
+
useOpenConfirmationDialog,
|
|
15
|
+
} from "@noya-app/noya-designsystem";
|
|
16
|
+
import {
|
|
17
|
+
InputIcon,
|
|
18
|
+
PlusIcon,
|
|
19
|
+
TrashIcon,
|
|
20
|
+
UploadIcon,
|
|
21
|
+
} from "@noya-app/noya-icons";
|
|
22
|
+
import { useFileDropTarget } from "@noya-app/react-utils";
|
|
23
|
+
import { GitManager, RepoFilesState } from "@noya-app/state-manager";
|
|
24
|
+
import React, {
|
|
25
|
+
forwardRef,
|
|
26
|
+
memo,
|
|
27
|
+
useCallback,
|
|
28
|
+
useEffect,
|
|
29
|
+
useImperativeHandle,
|
|
30
|
+
useMemo,
|
|
31
|
+
useRef,
|
|
32
|
+
useState,
|
|
33
|
+
} from "react";
|
|
34
|
+
import { type GitFileItem, isTempItem } from "./git/gitFileTree";
|
|
35
|
+
import { useGitFileOperations } from "./git/useGitFileOperations";
|
|
36
|
+
import { useGitFileTree } from "./git/useGitFileTree";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns true only after the condition has been true for the specified delay.
|
|
40
|
+
* Resets immediately when condition becomes false.
|
|
41
|
+
*/
|
|
42
|
+
function useDelayedTrue(condition: boolean, delayMs: number): boolean {
|
|
43
|
+
const [delayedValue, setDelayedValue] = useState(false);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!condition) {
|
|
47
|
+
setDelayedValue(false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const timeout = setTimeout(() => {
|
|
52
|
+
setDelayedValue(true);
|
|
53
|
+
}, delayMs);
|
|
54
|
+
|
|
55
|
+
return () => clearTimeout(timeout);
|
|
56
|
+
}, [condition, delayMs]);
|
|
57
|
+
|
|
58
|
+
return delayedValue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type MenuAction = "rename" | "delete" | "addFile";
|
|
62
|
+
type HeaderMenuAction = "newFile" | "uploadFiles";
|
|
63
|
+
|
|
64
|
+
export { type GitFileItem } from "./git/gitFileTree";
|
|
65
|
+
|
|
66
|
+
export type GitFileExplorerRef = {
|
|
67
|
+
addFile: () => void;
|
|
68
|
+
delete: (selectedIds: string[]) => void;
|
|
69
|
+
rename: (selectedId: string) => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type GitFileExplorerProps = {
|
|
73
|
+
repoId: string;
|
|
74
|
+
repoFilesState: RepoFilesState;
|
|
75
|
+
gitManager: GitManager;
|
|
76
|
+
selectedIds?: string[];
|
|
77
|
+
onSelectionChange?: (selectedIds: string[]) => void;
|
|
78
|
+
onFileSelect?: (filePath: string) => void;
|
|
79
|
+
className?: string;
|
|
80
|
+
title?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const GitFileExplorerInner = memo(
|
|
84
|
+
forwardRef<GitFileExplorerRef, GitFileExplorerProps>(function GitFileExplorer(
|
|
85
|
+
{
|
|
86
|
+
repoId,
|
|
87
|
+
repoFilesState,
|
|
88
|
+
gitManager,
|
|
89
|
+
selectedIds: selectedIdsProp,
|
|
90
|
+
onSelectionChange,
|
|
91
|
+
onFileSelect,
|
|
92
|
+
className,
|
|
93
|
+
title = "Files",
|
|
94
|
+
},
|
|
95
|
+
ref
|
|
96
|
+
) {
|
|
97
|
+
// Selection state
|
|
98
|
+
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
|
|
99
|
+
[]
|
|
100
|
+
);
|
|
101
|
+
const selectedIds = selectedIdsProp ?? internalSelectedIds;
|
|
102
|
+
const baseSetSelectedIds = onSelectionChange ?? setInternalSelectedIds;
|
|
103
|
+
|
|
104
|
+
// Refs
|
|
105
|
+
const dropTargetRef = useRef<HTMLDivElement | null>(null);
|
|
106
|
+
const collectionRef = useRef<CollectionRef>(null);
|
|
107
|
+
|
|
108
|
+
// Dialogs
|
|
109
|
+
const openConfirmationDialog = useOpenConfirmationDialog();
|
|
110
|
+
|
|
111
|
+
// Tree state
|
|
112
|
+
const {
|
|
113
|
+
visibleItems,
|
|
114
|
+
getExpanded,
|
|
115
|
+
setExpanded,
|
|
116
|
+
toggleExpanded,
|
|
117
|
+
addTempItem,
|
|
118
|
+
clearTempItem,
|
|
119
|
+
} = useGitFileTree({ repoFilesState });
|
|
120
|
+
|
|
121
|
+
// Wrap selection change to also trigger onFileSelect for single file selection
|
|
122
|
+
const setSelectedIds = useCallback(
|
|
123
|
+
(ids: string[]) => {
|
|
124
|
+
baseSetSelectedIds(ids);
|
|
125
|
+
|
|
126
|
+
// If a single file is selected, trigger onFileSelect
|
|
127
|
+
if (ids.length === 1) {
|
|
128
|
+
const item = visibleItems.find((i) => i.id === ids[0]);
|
|
129
|
+
if (item && !item.isDirectory) {
|
|
130
|
+
onFileSelect?.(item.path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[baseSetSelectedIds, visibleItems, onFileSelect]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// File operations
|
|
138
|
+
const {
|
|
139
|
+
isUploading,
|
|
140
|
+
renameFile,
|
|
141
|
+
deleteFiles,
|
|
142
|
+
uploadFiles,
|
|
143
|
+
handleFileDrop,
|
|
144
|
+
} = useGitFileOperations({ repoId, repoFilesState, gitManager });
|
|
145
|
+
|
|
146
|
+
// Drop target
|
|
147
|
+
const { dropTargetProps, isDropTargetActive } = useFileDropTarget(
|
|
148
|
+
dropTargetRef,
|
|
149
|
+
handleFileDrop
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Handlers
|
|
153
|
+
const handleAddFile = useCallback(() => {
|
|
154
|
+
const newItem = addTempItem();
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
collectionRef.current?.editName(newItem.id);
|
|
157
|
+
}, 50);
|
|
158
|
+
}, [addTempItem]);
|
|
159
|
+
|
|
160
|
+
const handleStartRename = useCallback((itemId: string) => {
|
|
161
|
+
collectionRef.current?.editName(itemId);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
const handleRename = useCallback(
|
|
165
|
+
async (item: GitFileItem, newName: string) => {
|
|
166
|
+
if (isTempItem(item)) {
|
|
167
|
+
clearTempItem();
|
|
168
|
+
if (!newName) return;
|
|
169
|
+
}
|
|
170
|
+
await renameFile(item, newName);
|
|
171
|
+
},
|
|
172
|
+
[renameFile, clearTempItem]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const handleDelete = useCallback(
|
|
176
|
+
async (items: GitFileItem[]) => {
|
|
177
|
+
if (items.length === 0) return;
|
|
178
|
+
|
|
179
|
+
const fileCount = items.filter((item) => !item.isDirectory).length;
|
|
180
|
+
const dirCount = items.filter((item) => item.isDirectory).length;
|
|
181
|
+
|
|
182
|
+
let description: string;
|
|
183
|
+
if (dirCount > 0 && fileCount > 0) {
|
|
184
|
+
description = `Are you sure you want to delete ${fileCount} file(s) and ${dirCount} folder(s)? This action cannot be undone.`;
|
|
185
|
+
} else if (dirCount > 0) {
|
|
186
|
+
description = `Are you sure you want to delete ${dirCount} folder(s) and all their contents? This action cannot be undone.`;
|
|
187
|
+
} else {
|
|
188
|
+
description = `Are you sure you want to delete ${fileCount} file(s)? This action cannot be undone.`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const confirmed = await openConfirmationDialog({
|
|
192
|
+
title: "Delete",
|
|
193
|
+
description,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!confirmed) return;
|
|
197
|
+
|
|
198
|
+
await deleteFiles(items);
|
|
199
|
+
setSelectedIds([]);
|
|
200
|
+
},
|
|
201
|
+
[deleteFiles, setSelectedIds, openConfirmationDialog]
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const handleDoubleClick = useCallback(
|
|
205
|
+
(itemId: string) => {
|
|
206
|
+
const item = visibleItems.find((i) => i.id === itemId);
|
|
207
|
+
if (!item) return;
|
|
208
|
+
|
|
209
|
+
if (item.isDirectory) {
|
|
210
|
+
toggleExpanded(item);
|
|
211
|
+
} else {
|
|
212
|
+
onFileSelect?.(item.path);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
[visibleItems, toggleExpanded, onFileSelect]
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Menu items
|
|
219
|
+
const headerMenuItems = useMemo(
|
|
220
|
+
() =>
|
|
221
|
+
createSectionedMenu<HeaderMenuAction>([
|
|
222
|
+
{
|
|
223
|
+
title: "New File",
|
|
224
|
+
value: "newFile",
|
|
225
|
+
icon: <PlusIcon />,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
title: "Upload Files",
|
|
229
|
+
value: "uploadFiles",
|
|
230
|
+
icon: <UploadIcon />,
|
|
231
|
+
},
|
|
232
|
+
]),
|
|
233
|
+
[]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const handleHeaderMenuAction = useCallback(
|
|
237
|
+
(action: HeaderMenuAction) => {
|
|
238
|
+
switch (action) {
|
|
239
|
+
case "newFile":
|
|
240
|
+
handleAddFile();
|
|
241
|
+
break;
|
|
242
|
+
case "uploadFiles":
|
|
243
|
+
uploadFiles();
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
[handleAddFile, uploadFiles]
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const handleMenuAction = useCallback(
|
|
251
|
+
(action: MenuAction, items: GitFileItem[]) => {
|
|
252
|
+
switch (action) {
|
|
253
|
+
case "addFile":
|
|
254
|
+
handleAddFile();
|
|
255
|
+
break;
|
|
256
|
+
case "rename":
|
|
257
|
+
if (items.length === 1) {
|
|
258
|
+
handleStartRename(items[0].id);
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
case "delete":
|
|
262
|
+
handleDelete(items);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
[handleAddFile, handleStartRename, handleDelete]
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const menuItems = useMemo(() => {
|
|
270
|
+
const singleSelected = selectedIds.length === 1;
|
|
271
|
+
const hasSelection = selectedIds.length > 0;
|
|
272
|
+
const selectedItem = singleSelected
|
|
273
|
+
? visibleItems.find((item) => selectedIds.includes(item.id))
|
|
274
|
+
: undefined;
|
|
275
|
+
const isFile = selectedItem && !selectedItem.isDirectory;
|
|
276
|
+
|
|
277
|
+
return createSectionedMenu<MenuAction>(
|
|
278
|
+
[
|
|
279
|
+
{
|
|
280
|
+
title: "Add File",
|
|
281
|
+
value: "addFile",
|
|
282
|
+
icon: <PlusIcon />,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
[
|
|
286
|
+
singleSelected &&
|
|
287
|
+
isFile && {
|
|
288
|
+
title: "Rename",
|
|
289
|
+
value: "rename",
|
|
290
|
+
icon: <InputIcon />,
|
|
291
|
+
},
|
|
292
|
+
hasSelection && {
|
|
293
|
+
title: "Delete",
|
|
294
|
+
value: "delete",
|
|
295
|
+
icon: <TrashIcon />,
|
|
296
|
+
},
|
|
297
|
+
]
|
|
298
|
+
);
|
|
299
|
+
}, [selectedIds, visibleItems]);
|
|
300
|
+
|
|
301
|
+
// Imperative handle
|
|
302
|
+
useImperativeHandle(ref, () => ({
|
|
303
|
+
addFile: handleAddFile,
|
|
304
|
+
delete: (ids: string[]) => {
|
|
305
|
+
const items = visibleItems.filter((item) => ids.includes(item.id));
|
|
306
|
+
handleDelete(items);
|
|
307
|
+
},
|
|
308
|
+
rename: (id: string) => {
|
|
309
|
+
handleStartRename(id);
|
|
310
|
+
},
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
// Render helpers
|
|
314
|
+
const isRefreshing = repoFilesState.fetchState === "refreshing";
|
|
315
|
+
const isLoading = repoFilesState.fetchState === "loading";
|
|
316
|
+
const showLoading = useDelayedTrue(isLoading, 500);
|
|
317
|
+
|
|
318
|
+
const renderEmptyState = useCallback(() => {
|
|
319
|
+
const hasError = !!repoFilesState.error;
|
|
320
|
+
let content = null;
|
|
321
|
+
|
|
322
|
+
if (isLoading && showLoading) {
|
|
323
|
+
content = <Small>Loading files...</Small>;
|
|
324
|
+
} else if (isLoading) {
|
|
325
|
+
// Still loading but haven't hit 500ms yet - show nothing
|
|
326
|
+
content = null;
|
|
327
|
+
} else if (hasError) {
|
|
328
|
+
content = <Small>Error: {repoFilesState.error}</Small>;
|
|
329
|
+
} else {
|
|
330
|
+
content = (
|
|
331
|
+
<>
|
|
332
|
+
<Small>No files yet</Small>
|
|
333
|
+
<Button onClick={handleAddFile}>Add a file</Button>
|
|
334
|
+
</>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className="n-mx-3 n-flex-1 n-flex n-flex-col n-items-center n-justify-center n-gap-2">
|
|
340
|
+
{content}
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}, [repoFilesState.error, handleAddFile, isLoading, showLoading]);
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<Section
|
|
347
|
+
title={title}
|
|
348
|
+
className={cx("n-px-3", className)}
|
|
349
|
+
right={
|
|
350
|
+
<div className="n-flex n-items-center n-gap-1">
|
|
351
|
+
{isRefreshing && <Small color="textDisabled">Syncing...</Small>}
|
|
352
|
+
{isUploading && <Small color="textDisabled">Uploading...</Small>}
|
|
353
|
+
<DropdownMenu
|
|
354
|
+
items={headerMenuItems}
|
|
355
|
+
onSelect={handleHeaderMenuAction}
|
|
356
|
+
>
|
|
357
|
+
<IconButton iconName="DotsVerticalIcon" />
|
|
358
|
+
</DropdownMenu>
|
|
359
|
+
</div>
|
|
360
|
+
}
|
|
361
|
+
>
|
|
362
|
+
<div
|
|
363
|
+
ref={dropTargetRef}
|
|
364
|
+
className={cx(
|
|
365
|
+
"n-flex n-flex-col n-flex-1 -n-mx-3",
|
|
366
|
+
isDropTargetActive &&
|
|
367
|
+
"n-bg-blue-500/10 n-ring-2 n-ring-blue-500 n-ring-inset"
|
|
368
|
+
)}
|
|
369
|
+
{...dropTargetProps}
|
|
370
|
+
>
|
|
371
|
+
<List
|
|
372
|
+
ref={collectionRef}
|
|
373
|
+
className="n-flex-1"
|
|
374
|
+
scrollable
|
|
375
|
+
expandable
|
|
376
|
+
renamable
|
|
377
|
+
size="small"
|
|
378
|
+
items={visibleItems}
|
|
379
|
+
getId={(item) => item.id}
|
|
380
|
+
getName={(item) => (isTempItem(item) ? "" : item.name)}
|
|
381
|
+
getDepth={(item) => item.depth}
|
|
382
|
+
getExpanded={getExpanded}
|
|
383
|
+
setExpanded={setExpanded}
|
|
384
|
+
getRenamable={(item) => !item.isDirectory}
|
|
385
|
+
getPlaceholder={() => "Enter file name"}
|
|
386
|
+
selectedIds={selectedIds}
|
|
387
|
+
onSelectionChange={setSelectedIds}
|
|
388
|
+
onDoubleClickItem={handleDoubleClick}
|
|
389
|
+
onRename={handleRename}
|
|
390
|
+
menuItems={menuItems}
|
|
391
|
+
onSelectMenuItem={handleMenuAction}
|
|
392
|
+
renderEmptyState={renderEmptyState}
|
|
393
|
+
renameSelectsBeforeDot
|
|
394
|
+
renderThumbnail={({ item, selected }) => (
|
|
395
|
+
<MediaThumbnail
|
|
396
|
+
iconName={item.isDirectory ? "FolderIcon" : undefined}
|
|
397
|
+
fileName={item.isDirectory ? undefined : item.name}
|
|
398
|
+
selected={selected}
|
|
399
|
+
size="small"
|
|
400
|
+
/>
|
|
401
|
+
)}
|
|
402
|
+
/>
|
|
403
|
+
</div>
|
|
404
|
+
</Section>
|
|
405
|
+
);
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
export const GitFileExplorer = memo(
|
|
410
|
+
forwardRef<
|
|
411
|
+
GitFileExplorerRef,
|
|
412
|
+
Omit<GitFileExplorerProps, "repoFilesState"> & {
|
|
413
|
+
repoFilesState: RepoFilesState | undefined;
|
|
414
|
+
}
|
|
415
|
+
>(function GitFileExplorer(props, ref) {
|
|
416
|
+
if (!props.repoFilesState) {
|
|
417
|
+
return (
|
|
418
|
+
<Section title={props.title} className={cx("n-px-3", props.className)}>
|
|
419
|
+
{null}
|
|
420
|
+
</Section>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<GitFileExplorerInner
|
|
426
|
+
{...props}
|
|
427
|
+
repoFilesState={props.repoFilesState}
|
|
428
|
+
ref={ref}
|
|
429
|
+
/>
|
|
430
|
+
);
|
|
431
|
+
})
|
|
432
|
+
);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export type GitFileItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
path: string;
|
|
5
|
+
isDirectory: boolean;
|
|
6
|
+
depth: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parses flat file paths into a tree structure for display
|
|
11
|
+
*/
|
|
12
|
+
export function parseFilesToTree(files: string[]): GitFileItem[] {
|
|
13
|
+
const items: GitFileItem[] = [];
|
|
14
|
+
const seenDirs = new Set<string>();
|
|
15
|
+
|
|
16
|
+
// Sort files to ensure directories come before their contents
|
|
17
|
+
const sortedFiles = [...files].sort();
|
|
18
|
+
|
|
19
|
+
for (const filePath of sortedFiles) {
|
|
20
|
+
const parts = filePath.split("/");
|
|
21
|
+
|
|
22
|
+
// Add directory entries for each parent directory
|
|
23
|
+
let currentPath = "";
|
|
24
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
25
|
+
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
|
26
|
+
if (!seenDirs.has(currentPath)) {
|
|
27
|
+
seenDirs.add(currentPath);
|
|
28
|
+
items.push({
|
|
29
|
+
id: `dir:${currentPath}`,
|
|
30
|
+
name: parts[i],
|
|
31
|
+
path: currentPath,
|
|
32
|
+
isDirectory: true,
|
|
33
|
+
depth: i,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Add the file entry
|
|
39
|
+
items.push({
|
|
40
|
+
id: `file:${filePath}`,
|
|
41
|
+
name: parts[parts.length - 1],
|
|
42
|
+
path: filePath,
|
|
43
|
+
isDirectory: false,
|
|
44
|
+
depth: parts.length - 1,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return items;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get visible items based on expanded state
|
|
53
|
+
*/
|
|
54
|
+
export function getVisibleItems(
|
|
55
|
+
items: GitFileItem[],
|
|
56
|
+
expandedDirs: Set<string>
|
|
57
|
+
): GitFileItem[] {
|
|
58
|
+
const visible: GitFileItem[] = [];
|
|
59
|
+
const collapsedPrefixes: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (const item of items) {
|
|
62
|
+
// Check if this item is under a collapsed directory
|
|
63
|
+
const isHidden = collapsedPrefixes.some((prefix) =>
|
|
64
|
+
item.path.startsWith(prefix + "/")
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (isHidden) continue;
|
|
68
|
+
|
|
69
|
+
visible.push(item);
|
|
70
|
+
|
|
71
|
+
// If this is a collapsed directory, track its prefix
|
|
72
|
+
if (item.isDirectory && !expandedDirs.has(item.path)) {
|
|
73
|
+
collapsedPrefixes.push(item.path);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return visible;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a temporary file item for new file creation
|
|
82
|
+
*/
|
|
83
|
+
export function createTempFileItem(name: string = ""): GitFileItem {
|
|
84
|
+
return {
|
|
85
|
+
id: "temp:new-file",
|
|
86
|
+
name,
|
|
87
|
+
path: name,
|
|
88
|
+
isDirectory: false,
|
|
89
|
+
depth: 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if an item is a temp item
|
|
95
|
+
*/
|
|
96
|
+
export function isTempItem(item: GitFileItem): boolean {
|
|
97
|
+
return item.id === "temp:new-file";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute new path for a renamed file
|
|
102
|
+
*/
|
|
103
|
+
export function computeRenamedPath(
|
|
104
|
+
oldPath: string,
|
|
105
|
+
newName: string
|
|
106
|
+
): string {
|
|
107
|
+
const pathParts = oldPath.split("/");
|
|
108
|
+
pathParts[pathParts.length - 1] = newName;
|
|
109
|
+
return pathParts.join("/");
|
|
110
|
+
}
|