@noya-app/noya-file-explorer 0.0.15 → 0.0.17
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 +13 -13
- package/CHANGELOG.md +27 -0
- package/dist/index.css +916 -843
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +11207 -45
- package/dist/index.d.ts +11207 -45
- package/dist/index.js +1361 -160
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1399 -156
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
- package/src/MediaCollection.tsx +156 -97
- package/src/ResourceExplorer.tsx +1141 -0
- package/src/__tests__/deleteMediaItems.test.ts +7 -7
- package/src/__tests__/getDepthMap.test.ts +7 -7
- package/src/__tests__/getParentDirectories.test.ts +6 -6
- package/src/__tests__/getVisibleItems.test.ts +9 -9
- package/src/__tests__/moveMediaInsideFolder.test.ts +11 -11
- package/src/__tests__/movePathsIntoTarget.test.ts +9 -9
- package/src/__tests__/moveUpAFolder.test.ts +7 -7
- package/src/__tests__/renameMediaItemAndDescendantPaths.test.ts +6 -6
- package/src/__tests__/updateExpandedMap.test.ts +8 -8
- package/src/__tests__/validateMediaItemRename.test.ts +11 -11
- package/src/index.ts +2 -1
- package/src/utils/files.ts +28 -37
- package/src/utils/handleFileDrop.ts +165 -0
- package/src/utils/resourceUtils.ts +329 -0
- package/src/formatByteSize.ts +0 -8
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createResourceTree,
|
|
3
|
+
Resource,
|
|
4
|
+
ResourceMap,
|
|
5
|
+
ResourceTree,
|
|
6
|
+
rootResource,
|
|
7
|
+
} from "@noya-app/noya-schemas";
|
|
8
|
+
import { path } from "imfs";
|
|
9
|
+
import { ancestorPaths } from "tree-visit";
|
|
10
|
+
|
|
11
|
+
export type FileKindFilter = "assets" | "directories" | "resources" | "all";
|
|
12
|
+
export type ExpandedMap = Record<string, boolean | undefined>;
|
|
13
|
+
|
|
14
|
+
export type GetVisibleItemsOptions = {
|
|
15
|
+
expandedMap: ExpandedMap;
|
|
16
|
+
fileKindFilter: FileKindFilter;
|
|
17
|
+
rootItemId: string;
|
|
18
|
+
tree: ResourceTree;
|
|
19
|
+
showAllDescendants: boolean;
|
|
20
|
+
showRootItem: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const getVisibleItems = ({
|
|
24
|
+
expandedMap,
|
|
25
|
+
fileKindFilter,
|
|
26
|
+
rootItemId,
|
|
27
|
+
tree,
|
|
28
|
+
showAllDescendants,
|
|
29
|
+
showRootItem,
|
|
30
|
+
}: GetVisibleItemsOptions) => {
|
|
31
|
+
const filteredItems: Resource[] = [];
|
|
32
|
+
const relativeRootItem =
|
|
33
|
+
tree.find(rootResource, (item) => item.id === rootItemId) ?? rootResource;
|
|
34
|
+
|
|
35
|
+
tree.visit(relativeRootItem, (item) => {
|
|
36
|
+
if (relativeRootItem.id === item.id) {
|
|
37
|
+
if (showRootItem) {
|
|
38
|
+
filteredItems.push(item);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (item.type === "file" && fileKindFilter === "all") {
|
|
43
|
+
filteredItems.push(item);
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
item.type === "asset" &&
|
|
47
|
+
(fileKindFilter === "assets" || fileKindFilter === "all")
|
|
48
|
+
) {
|
|
49
|
+
filteredItems.push(item);
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
item.type === "directory" &&
|
|
53
|
+
(fileKindFilter === "directories" || fileKindFilter === "all")
|
|
54
|
+
) {
|
|
55
|
+
filteredItems.push(item);
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
item.type === "resource" &&
|
|
59
|
+
(fileKindFilter === "resources" || fileKindFilter === "all")
|
|
60
|
+
) {
|
|
61
|
+
filteredItems.push(item);
|
|
62
|
+
}
|
|
63
|
+
if (!expandedMap[item.id] || !showAllDescendants) return "skip";
|
|
64
|
+
});
|
|
65
|
+
return filteredItems;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validates if a basename follows typical file naming conventions
|
|
70
|
+
* Disallows:
|
|
71
|
+
* - Path separators (/ and \)
|
|
72
|
+
* - Characters typically invalid in filesystems (<, >, :, ", |, ?, *)
|
|
73
|
+
* - Empty names
|
|
74
|
+
*/
|
|
75
|
+
export const basenameValidator = (basename: string): boolean => {
|
|
76
|
+
// Check for empty string
|
|
77
|
+
if (!basename || basename.trim() === "") return false;
|
|
78
|
+
|
|
79
|
+
// Simple regex matching common invalid filesystem characters
|
|
80
|
+
const invalidCharsRegex = /[/\\<>:"|?*]/;
|
|
81
|
+
return !invalidCharsRegex.test(basename);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const validateResourceRename = ({
|
|
85
|
+
basename,
|
|
86
|
+
selectedItemPath,
|
|
87
|
+
media,
|
|
88
|
+
}: {
|
|
89
|
+
basename: string;
|
|
90
|
+
selectedItemPath: string;
|
|
91
|
+
media: ResourceMap;
|
|
92
|
+
}): boolean => {
|
|
93
|
+
if (!basenameValidator(basename)) return false;
|
|
94
|
+
const newItemPath = path.join(path.dirname(selectedItemPath), basename);
|
|
95
|
+
// traverse the tree and check if there is already a path with the new name
|
|
96
|
+
const newPathExists = media[newItemPath];
|
|
97
|
+
if (newPathExists) return false;
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const movePathsIntoTarget = ({
|
|
102
|
+
media,
|
|
103
|
+
sourceItemPaths,
|
|
104
|
+
targetItemPath,
|
|
105
|
+
tree,
|
|
106
|
+
}: {
|
|
107
|
+
media: ResourceMap;
|
|
108
|
+
sourceItemPaths: string[];
|
|
109
|
+
targetItemPath: string;
|
|
110
|
+
tree: ResourceTree;
|
|
111
|
+
}) => {
|
|
112
|
+
const ancestors = ancestorPaths(
|
|
113
|
+
sourceItemPaths.map((path) => path.split("/"))
|
|
114
|
+
);
|
|
115
|
+
const mediaClone = { ...media };
|
|
116
|
+
for (const ancestor of ancestors) {
|
|
117
|
+
const ancestorPath = ancestor.join("/");
|
|
118
|
+
const ancestorItem = mediaClone[ancestorPath];
|
|
119
|
+
const newAncestorPath = path.join(
|
|
120
|
+
targetItemPath,
|
|
121
|
+
path.basename(ancestorPath)
|
|
122
|
+
);
|
|
123
|
+
if (!ancestorItem) continue;
|
|
124
|
+
const descendantPaths = tree
|
|
125
|
+
.flat(ancestorItem)
|
|
126
|
+
.map((item) => tree.idToPathMap.get(item.id));
|
|
127
|
+
for (const descendantPath of descendantPaths) {
|
|
128
|
+
if (!descendantPath) continue;
|
|
129
|
+
const newDescendantPath = descendantPath.replace(
|
|
130
|
+
ancestorPath,
|
|
131
|
+
newAncestorPath
|
|
132
|
+
);
|
|
133
|
+
const newPathIsValid = validateResourceRename({
|
|
134
|
+
basename: path.basename(descendantPath),
|
|
135
|
+
selectedItemPath: newDescendantPath,
|
|
136
|
+
media,
|
|
137
|
+
});
|
|
138
|
+
if (newPathIsValid) {
|
|
139
|
+
mediaClone[newDescendantPath] = mediaClone[descendantPath];
|
|
140
|
+
delete mediaClone[descendantPath];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return mediaClone;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const moveUpAFolder = ({
|
|
148
|
+
tree,
|
|
149
|
+
media,
|
|
150
|
+
selectedIds,
|
|
151
|
+
}: {
|
|
152
|
+
tree: ResourceTree;
|
|
153
|
+
media: ResourceMap;
|
|
154
|
+
selectedIds: string[];
|
|
155
|
+
}) => {
|
|
156
|
+
const indexPath = tree.findPath(
|
|
157
|
+
rootResource,
|
|
158
|
+
(item) => item.id === selectedIds[0]
|
|
159
|
+
);
|
|
160
|
+
if (!indexPath) return;
|
|
161
|
+
|
|
162
|
+
// Get the grandparent folder (where items will be moved to)
|
|
163
|
+
const grandparentFolder = tree.access(
|
|
164
|
+
rootResource,
|
|
165
|
+
indexPath.slice(0, indexPath.length - 2)
|
|
166
|
+
);
|
|
167
|
+
const grandparentFolderPath = tree.idToPathMap.get(grandparentFolder.id);
|
|
168
|
+
if (!grandparentFolderPath) return;
|
|
169
|
+
|
|
170
|
+
const sourceItemPaths = selectedIds
|
|
171
|
+
.map((id) => tree.idToPathMap.get(id))
|
|
172
|
+
.filter((path): path is string => Boolean(path));
|
|
173
|
+
return movePathsIntoTarget({
|
|
174
|
+
media,
|
|
175
|
+
targetItemPath: grandparentFolderPath,
|
|
176
|
+
sourceItemPaths,
|
|
177
|
+
tree,
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const getDepthMap = (
|
|
182
|
+
item: Resource,
|
|
183
|
+
tree: ResourceTree,
|
|
184
|
+
showAllDescendants: boolean
|
|
185
|
+
): Record<string, number> => {
|
|
186
|
+
const depthMap: Record<string, number> = {};
|
|
187
|
+
tree.visit(item, (item, indexPath) => {
|
|
188
|
+
if (showAllDescendants) {
|
|
189
|
+
depthMap[item.id] = Math.max(indexPath.length - 1, 0);
|
|
190
|
+
} else {
|
|
191
|
+
depthMap[item.id] = 0;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return depthMap;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const updateExpandedMap = ({
|
|
198
|
+
item,
|
|
199
|
+
expanded,
|
|
200
|
+
expandable,
|
|
201
|
+
expandedMap,
|
|
202
|
+
tree,
|
|
203
|
+
}: {
|
|
204
|
+
item: Resource;
|
|
205
|
+
expanded: boolean;
|
|
206
|
+
expandable: boolean;
|
|
207
|
+
expandedMap: ExpandedMap;
|
|
208
|
+
tree: ResourceTree;
|
|
209
|
+
}): ExpandedMap => {
|
|
210
|
+
const newExpandedMap = { ...expandedMap };
|
|
211
|
+
const inner = (item: Resource, expanded: boolean) => {
|
|
212
|
+
if (!expandable) return {};
|
|
213
|
+
// Don't allow toggling the fake root
|
|
214
|
+
if (item.id === rootResource.id) return {};
|
|
215
|
+
if (!expanded) {
|
|
216
|
+
// recursively collapse all children
|
|
217
|
+
const children = tree.getChildren(item, []);
|
|
218
|
+
children.forEach((child) => inner(child, false));
|
|
219
|
+
}
|
|
220
|
+
newExpandedMap[item.id] = expanded;
|
|
221
|
+
};
|
|
222
|
+
inner(item, expanded);
|
|
223
|
+
return newExpandedMap;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const deleteResources = ({
|
|
227
|
+
selectedIds,
|
|
228
|
+
media,
|
|
229
|
+
tree,
|
|
230
|
+
}: {
|
|
231
|
+
selectedIds: string[];
|
|
232
|
+
media: ResourceMap;
|
|
233
|
+
tree: ResourceTree;
|
|
234
|
+
}): ResourceMap => {
|
|
235
|
+
const itemsToDelete = selectedIds.flatMap((mediaItemId) => {
|
|
236
|
+
const mediaItem = tree.resourcesWithRoot.find(
|
|
237
|
+
(item) => item.id === mediaItemId
|
|
238
|
+
);
|
|
239
|
+
if (!mediaItem) return [];
|
|
240
|
+
return tree.flat(mediaItem);
|
|
241
|
+
});
|
|
242
|
+
const itemKeysToDelete = new Set(
|
|
243
|
+
itemsToDelete.map((item) => tree.idToPathMap.get(item.id))
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return Object.fromEntries(
|
|
247
|
+
Object.entries(media).filter(([key]) => !itemKeysToDelete.has(key))
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const moveMediaInsideFolder = ({
|
|
252
|
+
sourceItemIds,
|
|
253
|
+
targetItemId,
|
|
254
|
+
media,
|
|
255
|
+
tree,
|
|
256
|
+
}: {
|
|
257
|
+
sourceItemIds: string[];
|
|
258
|
+
targetItemId: string;
|
|
259
|
+
media: ResourceMap;
|
|
260
|
+
tree: ResourceTree;
|
|
261
|
+
}) => {
|
|
262
|
+
const targetItemPath = tree.idToPathMap.get(targetItemId);
|
|
263
|
+
if (!targetItemPath) return media;
|
|
264
|
+
|
|
265
|
+
const sourceItemPaths = sourceItemIds
|
|
266
|
+
.map((id) => tree.idToPathMap.get(id))
|
|
267
|
+
.filter((path): path is string => Boolean(path));
|
|
268
|
+
|
|
269
|
+
return movePathsIntoTarget({
|
|
270
|
+
media,
|
|
271
|
+
sourceItemPaths,
|
|
272
|
+
targetItemPath,
|
|
273
|
+
tree,
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const getParentDirectories = (
|
|
278
|
+
resourceMap: ResourceMap,
|
|
279
|
+
folderId: string
|
|
280
|
+
) => {
|
|
281
|
+
const tree = createResourceTree(resourceMap);
|
|
282
|
+
|
|
283
|
+
const indexPath = tree.findPath(rootResource, (item) => item.id === folderId);
|
|
284
|
+
|
|
285
|
+
if (!indexPath) return [rootResource];
|
|
286
|
+
|
|
287
|
+
return tree.accessPath(rootResource, indexPath);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export const renameResourceAndDescendantPaths = ({
|
|
291
|
+
newName,
|
|
292
|
+
selectedItemPath,
|
|
293
|
+
media,
|
|
294
|
+
tree,
|
|
295
|
+
}: {
|
|
296
|
+
newName: string;
|
|
297
|
+
selectedItemPath: string;
|
|
298
|
+
media: ResourceMap;
|
|
299
|
+
tree: ResourceTree;
|
|
300
|
+
}) => {
|
|
301
|
+
const mediaClone = { ...media };
|
|
302
|
+
const selectedItem = mediaClone[selectedItemPath];
|
|
303
|
+
if (!selectedItem) return mediaClone;
|
|
304
|
+
|
|
305
|
+
const parentPath = path.dirname(selectedItemPath);
|
|
306
|
+
const newItemPath = path.join(parentPath, newName);
|
|
307
|
+
|
|
308
|
+
const descendants = tree
|
|
309
|
+
.flat(selectedItem)
|
|
310
|
+
.map((item) => tree.idToPathMap.get(item.id));
|
|
311
|
+
|
|
312
|
+
// Update all descendants' paths
|
|
313
|
+
for (const descendantPath of descendants) {
|
|
314
|
+
if (!descendantPath) continue;
|
|
315
|
+
|
|
316
|
+
// Use the same approach as movePathsIntoTarget
|
|
317
|
+
const newDescendantPath = descendantPath.replace(
|
|
318
|
+
selectedItemPath,
|
|
319
|
+
newItemPath
|
|
320
|
+
);
|
|
321
|
+
mediaClone[newDescendantPath] = {
|
|
322
|
+
...mediaClone[descendantPath],
|
|
323
|
+
path: newDescendantPath,
|
|
324
|
+
};
|
|
325
|
+
delete mediaClone[descendantPath];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return mediaClone;
|
|
329
|
+
};
|
package/src/formatByteSize.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
const byteSizeUnits = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
|
2
|
-
|
|
3
|
-
export function formatByteSize(size: number) {
|
|
4
|
-
const unitIndex = Math.floor(Math.log(size) / Math.log(1024));
|
|
5
|
-
const unit = byteSizeUnits[unitIndex];
|
|
6
|
-
const value = size / Math.pow(1024, unitIndex);
|
|
7
|
-
return `${value.toFixed(1)} ${unit}`;
|
|
8
|
-
}
|