@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.
@@ -0,0 +1,184 @@
1
+ import { GitManager, RepoFilesState } from "@noya-app/state-manager";
2
+ import {
3
+ collectDroppedFiles,
4
+ readFileAsText,
5
+ } from "@noya-app/react-utils";
6
+ import { fileOpen } from "browser-fs-access";
7
+ import { DragEvent, useCallback, useState } from "react";
8
+ import {
9
+ computeRenamedPath,
10
+ type GitFileItem,
11
+ isTempItem,
12
+ } from "./gitFileTree";
13
+
14
+ export type UseGitFileOperationsOptions = {
15
+ repoId: string;
16
+ repoFilesState: RepoFilesState;
17
+ gitManager: GitManager;
18
+ };
19
+
20
+ export function useGitFileOperations({
21
+ repoId,
22
+ repoFilesState,
23
+ gitManager,
24
+ }: UseGitFileOperationsOptions) {
25
+ const [isUploading, setIsUploading] = useState(false);
26
+
27
+ const createFile = useCallback(
28
+ async (path: string, content: string = "") => {
29
+ try {
30
+ await gitManager.patchFiles(
31
+ repoId,
32
+ { create: [{ path, content }] },
33
+ repoFilesState.ref
34
+ );
35
+ } catch (error) {
36
+ alert(`Failed to create file: ${error}`);
37
+ throw error;
38
+ }
39
+ },
40
+ [gitManager, repoId, repoFilesState.ref]
41
+ );
42
+
43
+ const renameFile = useCallback(
44
+ async (item: GitFileItem, newName: string) => {
45
+ // Handle temp item (new file creation)
46
+ if (isTempItem(item)) {
47
+ if (!newName) return;
48
+ await createFile(newName);
49
+ return;
50
+ }
51
+
52
+ // Handle actual rename
53
+ if (item.isDirectory) {
54
+ alert("Renaming directories is not yet supported");
55
+ return;
56
+ }
57
+
58
+ // If name didn't change, do nothing
59
+ if (newName === item.name) return;
60
+
61
+ const newPath = computeRenamedPath(item.path, newName);
62
+
63
+ try {
64
+ await gitManager.patchFiles(
65
+ repoId,
66
+ { rename: [{ oldPath: item.path, newPath }] },
67
+ repoFilesState.ref
68
+ );
69
+ } catch (error) {
70
+ alert(`Failed to rename file: ${error}`);
71
+ throw error;
72
+ }
73
+ },
74
+ [gitManager, repoId, repoFilesState.ref, createFile]
75
+ );
76
+
77
+ const deleteFiles = useCallback(
78
+ async (items: GitFileItem[]) => {
79
+ // Collect all file paths to delete
80
+ const pathsToDelete = new Set<string>();
81
+
82
+ for (const item of items) {
83
+ if (item.isDirectory) {
84
+ // Find all files under this directory
85
+ const dirPrefix = item.path + "/";
86
+ for (const filePath of repoFilesState.files) {
87
+ if (filePath.startsWith(dirPrefix)) {
88
+ pathsToDelete.add(filePath);
89
+ }
90
+ }
91
+ } else {
92
+ pathsToDelete.add(item.path);
93
+ }
94
+ }
95
+
96
+ if (pathsToDelete.size === 0) {
97
+ return;
98
+ }
99
+
100
+ try {
101
+ await gitManager.patchFiles(
102
+ repoId,
103
+ { delete: Array.from(pathsToDelete).map((path) => ({ path })) },
104
+ repoFilesState.ref
105
+ );
106
+ } catch (error) {
107
+ alert(`Failed to delete files: ${error}`);
108
+ throw error;
109
+ }
110
+ },
111
+ [gitManager, repoId, repoFilesState.ref, repoFilesState.files]
112
+ );
113
+
114
+ const uploadFiles = useCallback(async () => {
115
+ try {
116
+ const files = await fileOpen({ multiple: true });
117
+ if (!files || (Array.isArray(files) && files.length === 0)) return;
118
+
119
+ const fileArray = Array.isArray(files) ? files : [files];
120
+ setIsUploading(true);
121
+
122
+ const createOperations = await Promise.all(
123
+ fileArray.map(async (file) => ({
124
+ path: file.name,
125
+ content: await readFileAsText(file),
126
+ }))
127
+ );
128
+
129
+ await gitManager.patchFiles(
130
+ repoId,
131
+ { create: createOperations },
132
+ repoFilesState.ref
133
+ );
134
+ } catch (error) {
135
+ if (error instanceof Error && error.name === "AbortError") {
136
+ // User cancelled
137
+ } else {
138
+ alert(`Failed to upload files: ${error}`);
139
+ }
140
+ } finally {
141
+ setIsUploading(false);
142
+ }
143
+ }, [gitManager, repoId, repoFilesState.ref]);
144
+
145
+ const handleFileDrop = useCallback(
146
+ async (event: DragEvent) => {
147
+ event.preventDefault();
148
+
149
+ try {
150
+ setIsUploading(true);
151
+ const droppedFiles = await collectDroppedFiles(event.dataTransfer);
152
+
153
+ if (droppedFiles.length === 0) return;
154
+
155
+ const createOperations = await Promise.all(
156
+ droppedFiles.map(async ({ file, relativePath }) => ({
157
+ path: relativePath,
158
+ content: await readFileAsText(file),
159
+ }))
160
+ );
161
+
162
+ await gitManager.patchFiles(
163
+ repoId,
164
+ { create: createOperations },
165
+ repoFilesState.ref
166
+ );
167
+ } catch (error) {
168
+ alert(`Failed to upload files: ${error}`);
169
+ } finally {
170
+ setIsUploading(false);
171
+ }
172
+ },
173
+ [gitManager, repoId, repoFilesState.ref]
174
+ );
175
+
176
+ return {
177
+ isUploading,
178
+ createFile,
179
+ renameFile,
180
+ deleteFiles,
181
+ uploadFiles,
182
+ handleFileDrop,
183
+ };
184
+ }
@@ -0,0 +1,91 @@
1
+ import { RepoFilesState } from "@noya-app/state-manager";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import {
4
+ createTempFileItem,
5
+ getVisibleItems,
6
+ type GitFileItem,
7
+ parseFilesToTree,
8
+ } from "./gitFileTree";
9
+
10
+ export type UseGitFileTreeOptions = {
11
+ repoFilesState: RepoFilesState;
12
+ };
13
+
14
+ export function useGitFileTree({ repoFilesState }: UseGitFileTreeOptions) {
15
+ const [expandedDirs, setExpandedDirs] = useState<Set<string>>(
16
+ () => new Set()
17
+ );
18
+ const [tempItem, setTempItem] = useState<GitFileItem | undefined>(undefined);
19
+
20
+ // Parse files into tree structure
21
+ const allItems = useMemo(
22
+ () => parseFilesToTree(repoFilesState.files),
23
+ [repoFilesState.files]
24
+ );
25
+
26
+ // Include temp item if present
27
+ const allItemsWithTemp = useMemo(() => {
28
+ if (!tempItem) return allItems;
29
+ return [...allItems, tempItem];
30
+ }, [allItems, tempItem]);
31
+
32
+ // Get visible items based on expanded state
33
+ const visibleItems = useMemo(
34
+ () => getVisibleItems(allItemsWithTemp, expandedDirs),
35
+ [allItemsWithTemp, expandedDirs]
36
+ );
37
+
38
+ const getExpanded = useCallback(
39
+ (item: GitFileItem) => {
40
+ if (!item.isDirectory) return undefined;
41
+ return expandedDirs.has(item.path);
42
+ },
43
+ [expandedDirs]
44
+ );
45
+
46
+ const setExpanded = useCallback((item: GitFileItem, expanded: boolean) => {
47
+ if (!item.isDirectory) return;
48
+ setExpandedDirs((prev) => {
49
+ const next = new Set(prev);
50
+ if (expanded) {
51
+ next.add(item.path);
52
+ } else {
53
+ next.delete(item.path);
54
+ }
55
+ return next;
56
+ });
57
+ }, []);
58
+
59
+ const toggleExpanded = useCallback((item: GitFileItem) => {
60
+ if (!item.isDirectory) return;
61
+ setExpandedDirs((prev) => {
62
+ const next = new Set(prev);
63
+ if (next.has(item.path)) {
64
+ next.delete(item.path);
65
+ } else {
66
+ next.add(item.path);
67
+ }
68
+ return next;
69
+ });
70
+ }, []);
71
+
72
+ const addTempItem = useCallback(() => {
73
+ const newItem = createTempFileItem();
74
+ setTempItem(newItem);
75
+ return newItem;
76
+ }, []);
77
+
78
+ const clearTempItem = useCallback(() => {
79
+ setTempItem(undefined);
80
+ }, []);
81
+
82
+ return {
83
+ visibleItems,
84
+ tempItem,
85
+ getExpanded,
86
+ setExpanded,
87
+ toggleExpanded,
88
+ addTempItem,
89
+ clearTempItem,
90
+ };
91
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import "./index.css";
2
+ export * from "./GitFileExplorer";
2
3
  export * from "./MediaCollection";
3
4
  export * from "./ResourceExplorer";
4
5
  export * from "./utils/contentType";
@@ -7,19 +7,10 @@ import {
7
7
  ResourceMap,
8
8
  } from "@noya-app/noya-schemas";
9
9
  import { Base64, uuid } from "@noya-app/noya-utils";
10
+ import { collectDroppedFiles } from "@noya-app/react-utils";
10
11
  import { path } from "imfs";
11
12
  import { getContentTypeFromFile } from "./contentType";
12
13
 
13
- function isDirectoryEntry(
14
- entry: FileSystemEntry
15
- ): entry is FileSystemDirectoryEntry {
16
- return entry.isDirectory;
17
- }
18
-
19
- function isFileEntry(entry: FileSystemEntry): entry is FileSystemFileEntry {
20
- return entry.isFile;
21
- }
22
-
23
14
  export async function handleDataTransfer({
24
15
  accessibleByFileId,
25
16
  dataTransfer,
@@ -32,80 +23,7 @@ export async function handleDataTransfer({
32
23
  resourceMap: ResourceMap;
33
24
  }): Promise<ResourceCreateMap | undefined> {
34
25
  try {
35
- const dataTransferItems = Array.from(dataTransfer.items ?? []);
36
-
37
- const supportsEntries = dataTransferItems.some(
38
- (item) => typeof item?.webkitGetAsEntry === "function"
39
- );
40
-
41
- type DroppedFile = { file: File; relativePath: string };
42
-
43
- const collectFromEntry = async (
44
- entry: FileSystemEntry,
45
- basePath: string
46
- ): Promise<DroppedFile[]> => {
47
- if (!entry) return [];
48
- if (isFileEntry(entry)) {
49
- const file: File = await new Promise((resolve) =>
50
- (entry as FileSystemFileEntry).file((f: File) => resolve(f))
51
- );
52
- return [
53
- {
54
- file,
55
- relativePath: path.join(basePath, file.name),
56
- },
57
- ];
58
- }
59
- if (isDirectoryEntry(entry)) {
60
- const reader = entry.createReader();
61
- const readAll = async (): Promise<FileSystemEntry[]> => {
62
- const all: FileSystemEntry[] = [];
63
- // readEntries may return partial results; loop until empty
64
- // eslint-disable-next-line no-constant-condition
65
- while (true) {
66
- const entries: FileSystemEntry[] = await new Promise((resolve) =>
67
- reader.readEntries((ents: FileSystemEntry[]) => resolve(ents))
68
- );
69
- if (!entries.length) break;
70
- all.push(...entries);
71
- }
72
- return all;
73
- };
74
- const children = await readAll();
75
- const results = await Promise.all(
76
- children.map((child) =>
77
- collectFromEntry(child, path.join(basePath, entry.name))
78
- )
79
- );
80
- return results.flat();
81
- }
82
- return [];
83
- };
84
-
85
- let dropped: DroppedFile[] = [];
86
-
87
- if (supportsEntries) {
88
- const topLevelEntries = dataTransferItems.flatMap((item) => {
89
- const entry = item.webkitGetAsEntry?.();
90
- if (!entry) return [];
91
- return [entry];
92
- });
93
- const nested = await Promise.all(
94
- topLevelEntries.map((entry) => collectFromEntry(entry, ""))
95
- );
96
- dropped = nested.flat();
97
- } else {
98
- const files = Array.from(dataTransfer.files);
99
- if (files.length === 0) return;
100
- dropped = files.map((file) => ({
101
- file,
102
- // Best effort: try webkitRelativePath; fall back to name
103
- relativePath:
104
- file.webkitRelativePath && file.webkitRelativePath.length > 0
105
- ? file.webkitRelativePath
106
- : file.name,
107
- }));
108
- }
26
+ const dropped = await collectDroppedFiles(dataTransfer);
109
27
 
110
28
  if (dropped.length === 0) return;
111
29