@noya-app/noya-file-explorer 0.0.31 → 0.0.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-file-explorer",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
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.76",
23
- "@noya-app/noya-icons": "0.1.16",
24
- "@noya-app/noya-multiplayer-react": "0.1.77",
22
+ "@noya-app/noya-designsystem": "0.1.78",
23
+ "@noya-app/noya-icons": "0.1.18",
24
+ "@noya-app/noya-multiplayer-react": "0.1.79",
25
25
  "@noya-app/noya-keymap": "0.1.4",
26
- "@noya-app/react-utils": "0.1.28",
26
+ "@noya-app/react-utils": "0.1.29",
27
27
  "@noya-app/noya-utils": "0.1.9",
28
- "@noya-app/noya-schemas": "0.1.8",
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
+ }