@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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +21 -0
- package/dist/index.css +268 -87
- 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
|
@@ -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
|
@@ -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
|
|
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
|
|