@deckbuilderkit/view-explorer 0.0.0-stage → 0.1.1

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,824 @@
1
+ import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from 'react';
2
+ import { previewKind } from '@deckbuilderkit/editor/options';
3
+ import {
4
+ ContextMenu,
5
+ type ContextMenuItem,
6
+ errorMessage,
7
+ errorOf,
8
+ fileIconOf,
9
+ FileNesting,
10
+ folderIconOf,
11
+ type NestingPatterns,
12
+ relPath,
13
+ statusOf,
14
+ TreeView,
15
+ type TreeViewHandle,
16
+ useAsync,
17
+ useDialogs,
18
+ useToast,
19
+ } from '@deckbuilderkit/ui';
20
+ import { multiDeleteEnabled } from '../options';
21
+ import { explorerApi as api, type FsEntry, type Project } from '../client';
22
+ import { fullPath, nodeId } from '../explorer-paths';
23
+ import { t } from '../i18n';
24
+ import { useCopyPath } from './copy-path';
25
+ import { PropertiesDialog } from './PropertiesDialog';
26
+ import type { FileUndo, UndoOp } from './file-undo';
27
+ import type { FsArchiveFormat, GitFile } from '@deckbuilderkit/server-core/protocol';
28
+ import { entriesOf, freeName, walkEntry } from './os-drop';
29
+ import { archiveStem, commonParent, compressPaths, takenIn as takenInDir } from './fs-actions';
30
+ import type { FindResult } from './tree-find';
31
+
32
+ interface Node {
33
+ project: string;
34
+ path: string;
35
+ name: string;
36
+ kind: FsEntry['kind'];
37
+ outside?: boolean;
38
+ target?: FsEntry['target'];
39
+ excluded?: true;
40
+ /** Files shown under this file (file nesting); they are in the same folder on disk. */
41
+ nested?: Node[];
42
+ }
43
+
44
+ const isRoot = (node: Node) => node.path === '';
45
+ const isDir = (node: Node) => node.kind === 'dir' || node.target === 'dir';
46
+ const isArchive = (node: Node) => !isDir(node) && previewKind(node.name) === 'archive';
47
+
48
+ export interface ProjectTreeProps {
49
+ project: Project;
50
+ expanded: string[];
51
+ onExpandedChange(ids: string[]): void;
52
+ onOpenFile(project: string, path: string): void;
53
+ /** A single click on a file; absent: a click only selects. */
54
+ onPreviewFile?(project: string, path: string): void;
55
+ /** Absent: no Open to the Side. */
56
+ onOpenFileToSide?(project: string, path: string): void;
57
+ onOpenTerminal(project: string, path: string): void;
58
+ /** The file chosen with Select for Compare, in any project. */
59
+ compareWith?: { project: string; path: string };
60
+ onSelectForCompare?(project: string, path: string): void;
61
+ /** Absent: no compare items in the menu. */
62
+ onCompare?(left: { project: string; path: string }, right: { project: string; path: string }): void;
63
+ /** Absent: no Open as Details in the menu. */
64
+ onOpenDetails?(project: string, path: string): void;
65
+ /** A file or folder was renamed or moved. */
66
+ onMoved(project: string, from: string, to: string): void;
67
+ onDeleted(project: string, path: string): void;
68
+ apiRef?: Ref<ProjectTreeApi>;
69
+ undo: FileUndo;
70
+ /** How many of these project paths (or files under them) are open with unsaved changes. */
71
+ dirtyCount?(project: string, paths: string[]): number;
72
+ /** Which entries the tree lists and in what order; a change reloads the tree. */
73
+ view?: TreeViewOptions;
74
+ /** Folders holding a single folder share a row. */
75
+ compact?: boolean;
76
+ /** Ask before deleting (default true); the unsaved-changes warning stays. */
77
+ confirmDelete?: boolean;
78
+ /** Ask before a drag and drop move (default false). */
79
+ confirmDragDrop?: boolean;
80
+ /** File nesting patterns; absent: files are not nested. */
81
+ nesting?: NestingPatterns;
82
+ /** Find in the tree: `highlight` marks matches and counts them on folders, `filter` also hides everything else. */
83
+ find?: { mode: 'highlight' | 'filter'; result: FindResult };
84
+ /** Git status badges on changed files and a dot on folders holding them (only while the root is expanded). */
85
+ gitDecorations?: boolean;
86
+ }
87
+
88
+ type Tone = 'ok' | 'warn' | 'err';
89
+ const RANK: Record<Tone, number> = { ok: 0, warn: 1, err: 2 };
90
+ interface GitMarks {
91
+ files: Map<string, { letter: string; tone: Tone }>;
92
+ dirs: Map<string, Tone>;
93
+ }
94
+
95
+ /** Badge per changed file (a conflict wins over its other entries) and the strongest tone per folder above one. */
96
+ function gitMarksOf(list: GitFile[]): GitMarks {
97
+ const files = new Map<string, { letter: string; tone: Tone }>();
98
+ const dirs = new Map<string, Tone>();
99
+ for (const file of list) {
100
+ const s = statusOf(file);
101
+ const mark = { letter: file.group === 'conflict' ? 'C' : s.letter, tone: s.tone };
102
+ const had = files.get(file.path);
103
+ if (!had || RANK[mark.tone] > RANK[had.tone]) files.set(file.path, mark);
104
+ for (let dir = relPath.dirname(file.path); dir !== '' && dir !== '.'; dir = relPath.dirname(dir)) {
105
+ const tone = dirs.get(dir);
106
+ if (!tone || RANK[mark.tone] > RANK[tone]) dirs.set(dir, mark.tone);
107
+ }
108
+ }
109
+ return { files, dirs };
110
+ }
111
+
112
+ export interface TreeViewOptions {
113
+ exclude: string[];
114
+ sort: string;
115
+ showExcluded: boolean;
116
+ }
117
+
118
+ const DID: Record<UndoOp['kind'], 'explorer.did.create' | 'explorer.did.rename' | 'explorer.did.move' | 'explorer.did.copy'> = {
119
+ create: 'explorer.did.create',
120
+ rename: 'explorer.did.rename',
121
+ move: 'explorer.did.move',
122
+ copy: 'explorer.did.copy',
123
+ };
124
+ const MAX_LISTED = 10;
125
+ const MAX_FORCED = 400;
126
+ const UPLOAD_PARALLEL = 3;
127
+ const LINK_FRESH_MS = 50_000;
128
+
129
+ /** What the sidebar header and the commands can ask of a tree. */
130
+ export interface ProjectTreeApi {
131
+ newFile(): void;
132
+ newFolder(): void;
133
+ refresh(): void;
134
+ collapseAll(): void;
135
+ /** Opens every folder loaded so far; the ones that load as a result open on the next call. */
136
+ expandAll(): void;
137
+ /** Some folder below the root is open. */
138
+ canCollapse(): boolean;
139
+ /** Expands the folders above `path` and selects it. */
140
+ reveal(path: string): void;
141
+ /** Scrolls to and selects `path` without changing which folders are expanded. */
142
+ scrollTo(path: string): void;
143
+ /** True while the tree's find box is open. */
144
+ finding(): boolean;
145
+ /** The selected folder, the folder of the selected file, or the root. */
146
+ selectedFolder(): string;
147
+ }
148
+
149
+ /** The lazy tree of a project's folder, with the file actions. */
150
+ export function ProjectTree({ project, expanded, onExpandedChange, onOpenFile, onPreviewFile, onOpenFileToSide, onOpenTerminal, compareWith, onSelectForCompare, onCompare, onOpenDetails, onMoved, onDeleted, apiRef, undo, dirtyCount, view, compact, confirmDelete = true, confirmDragDrop = false, nesting, find, gitDecorations = false }: ProjectTreeProps) {
151
+ const tree = useRef<TreeViewHandle<Node>>(null);
152
+ const clipboard = useRef<{ mode: 'copy' | 'cut'; paths: string[] }>();
153
+ const { confirm, choose, prompt, element } = useDialogs();
154
+ const [creating, setCreating] = useState<{ dir: string; kind: 'file' | 'dir' }>();
155
+ const toast = useToast();
156
+ const copyPath = useCopyPath();
157
+ const [menu, setMenu] = useState<{ at: { x: number; y: number }; node: Node; selection: Node[] }>();
158
+ const [properties, setProperties] = useState<string>();
159
+ const editors = useAsync(() => api.editors(), []);
160
+
161
+ const folderFiles = useRef(new Map<string, string[]>());
162
+ const outLink = useRef<{ id: string; url: string; name: string; at: number }>();
163
+
164
+ const roots = useMemo<Node[]>(() => [{ project: project.id, path: '', name: project.name, kind: 'dir' }], [project.id, project.name]);
165
+
166
+ const forced = useMemo(
167
+ () => (find?.mode === 'filter' ? new Set([...find.result.counts.keys()].slice(0, MAX_FORCED).map((dir) => nodeId(project.id, dir))) : undefined),
168
+ [find, project.id],
169
+ );
170
+ const visibleOf = useMemo(() => {
171
+ if (find?.mode !== 'filter') return undefined;
172
+ const { matches, counts } = find.result;
173
+ const shows = (n: Node): boolean => n.path === '' || matches.has(n.path) || counts.has(n.path) || !!n.nested?.some(shows);
174
+ return shows;
175
+ }, [find]);
176
+ const markOf = useMemo(() => {
177
+ if (!find) return undefined;
178
+ const { matches, counts } = find.result;
179
+ return (n: Node) => {
180
+ const positions = matches.get(n.path);
181
+ const count = isDir(n) ? counts.get(n.path) : undefined;
182
+ return positions || count ? { positions: positions ?? [], count } : undefined;
183
+ };
184
+ }, [find]);
185
+
186
+ const run = async (job: () => Promise<void>) => {
187
+ try {
188
+ await job();
189
+ } catch (err) {
190
+ toast({ message: errorMessage(errorOf(err)), kind: 'error' });
191
+ }
192
+ };
193
+
194
+ const expand = (id: string) => {
195
+ if (!expanded.includes(id)) onExpandedChange([...expanded, id]);
196
+ };
197
+
198
+ const nester = useMemo(() => nesting && new FileNesting(nesting), [JSON.stringify(nesting)]);
199
+ const nestedOf = useRef(new Map<string, Node[]>());
200
+ const nestParent = useRef(new Map<string, string>());
201
+
202
+ const loadChildren = async (node: Node): Promise<Node[]> => {
203
+ if (!isDir(node)) return nestedOf.current.get(nodeId(project.id, node.path)) ?? [];
204
+ const entries = await api.fsList(project.id, node.path, view && { exclude: view.exclude, sort: view.sort, all: view.showExcluded });
205
+ folderFiles.current.set(
206
+ nodeId(project.id, node.path),
207
+ entries.filter((e) => e.kind !== 'dir' && e.target !== 'dir').map((e) => e.name),
208
+ );
209
+ const nodes = entries.map((e): Node => ({
210
+ project: project.id,
211
+ path: relPath.join(node.path, e.name),
212
+ name: e.name,
213
+ kind: e.kind,
214
+ outside: e.outside,
215
+ target: e.target,
216
+ excluded: e.excluded,
217
+ }));
218
+ if (!nester) return nodes;
219
+ const byName = new Map(nodes.filter((n) => !isDir(n)).map((n) => [n.name, n]));
220
+ const groups = nester.nest([...byName.keys()], node.path ? relPath.basename(node.path) : project.name);
221
+ const hidden = new Set<string>();
222
+ const changed: string[] = [];
223
+ for (const [parent, children] of groups) {
224
+ const id = nodeId(project.id, relPath.join(node.path, parent));
225
+ const list = [...children].map((c) => byName.get(c)!);
226
+ for (const child of list) {
227
+ hidden.add(child.name);
228
+ nestParent.current.set(nodeId(project.id, child.path), id);
229
+ }
230
+ if (list.length) {
231
+ byName.get(parent)!.nested = list;
232
+ nestedOf.current.set(id, list);
233
+ changed.push(id);
234
+ } else {
235
+ nestedOf.current.delete(id);
236
+ }
237
+ }
238
+ // Nested files that were loaded before this reload show the new list.
239
+ setTimeout(() => changed.forEach((id) => tree.current?.reload(id)), 0);
240
+ return nodes.filter((n) => !hidden.has(n.name));
241
+ };
242
+
243
+ const iconOf = (n: Node) => {
244
+ if (!isDir(n)) return fileIconOf(n.name).icon;
245
+ return n.kind === 'link' ? 'folder-link' : folderIconOf(n.name, folderFiles.current.get(nodeId(project.id, n.path)));
246
+ };
247
+
248
+ const [git, setGit] = useState<GitMarks>();
249
+ const gitOn = useRef(false);
250
+ gitOn.current = gitDecorations;
251
+ const gitSeq = useRef(0);
252
+ const gitKey = useRef('');
253
+ const refreshGit = () => {
254
+ const seq = ++gitSeq.current;
255
+ const done = (files?: GitFile[]) => {
256
+ if (seq !== gitSeq.current) return;
257
+ const key = files ? JSON.stringify(files) : '';
258
+ if (key === gitKey.current) return;
259
+ gitKey.current = key;
260
+ setGit(files && gitMarksOf(files));
261
+ };
262
+ if (!gitOn.current) return done();
263
+ api.gitStatus(project.id).then(
264
+ (status) => done(status.files),
265
+ () => done(),
266
+ );
267
+ };
268
+ useEffect(refreshGit, [project.id, gitOn.current]);
269
+
270
+ const decorationOf = (n: Node) => {
271
+ if (!git || n.outside || isRoot(n)) return undefined;
272
+ const file = git.files.get(n.path);
273
+ if (file) return { className: `git-${file.tone}`, badge: file.letter };
274
+ const tone = isDir(n) ? git.dirs.get(n.path) : undefined;
275
+ return tone && { className: `git-${tone} git-dir`, badge: '•' };
276
+ };
277
+
278
+ const reloadDir = (path: string) => {
279
+ folderFiles.current.delete(nodeId(project.id, path));
280
+ tree.current?.reload(nodeId(project.id, path));
281
+ refreshGit();
282
+ };
283
+
284
+ useEffect(() => {
285
+ const refresh = () => {
286
+ if (document.visibilityState !== 'visible') return;
287
+ folderFiles.current.clear();
288
+ tree.current?.reload();
289
+ refreshGit();
290
+ };
291
+ const timer = setInterval(refresh, 4000);
292
+ window.addEventListener('focus', refresh);
293
+ return () => {
294
+ clearInterval(timer);
295
+ window.removeEventListener('focus', refresh);
296
+ };
297
+ }, [project.id]);
298
+
299
+ const viewKey = JSON.stringify([view, nesting]);
300
+ const firstView = useRef(viewKey);
301
+ useEffect(() => {
302
+ if (firstView.current === viewKey) return;
303
+ firstView.current = viewKey;
304
+ folderFiles.current.clear();
305
+ tree.current?.reload();
306
+ }, [viewKey]);
307
+
308
+ const rootNode = roots[0]!;
309
+ const expandAbove = (path: string) => {
310
+ const missing: string[] = [];
311
+ for (let dir = relPath.dirname(path); ; dir = relPath.dirname(dir)) {
312
+ const id = nodeId(project.id, dir);
313
+ if (!expanded.includes(id)) missing.push(id);
314
+ if (dir === '') break;
315
+ }
316
+ const holder = nestParent.current.get(nodeId(project.id, path));
317
+ if (holder && !expanded.includes(holder)) missing.push(holder);
318
+ if (missing.length) onExpandedChange([...expanded, ...missing]);
319
+ };
320
+ const scrollTo = (path: string) => tree.current?.reveal(nodeId(project.id, path));
321
+ const reveal = (path: string) => {
322
+ expandAbove(path);
323
+ scrollTo(path);
324
+ };
325
+
326
+ const open = (node: Node) => {
327
+ if (node.outside) return;
328
+ if (isDir(node)) {
329
+ const id = nodeId(project.id, node.path);
330
+ onExpandedChange(expanded.includes(id) ? expanded.filter((x) => x !== id) : [...expanded, id]);
331
+ } else {
332
+ if (find) expandAbove(node.path);
333
+ onOpenFile(project.id, node.path);
334
+ }
335
+ };
336
+
337
+ const record = (op: UndoOp, name: string, key?: string) => {
338
+ undo.push(op);
339
+ toast({ key, message: t(DID[op.kind], { name }), action: { label: t('explorer.undo'), run: () => void undoOp(op) } });
340
+ };
341
+ const labelOf = (paths: string[]) => (paths.length === 1 ? relPath.basename(paths[0]!) : t('explorer.items', { count: paths.length }));
342
+
343
+ const create = (kind: 'file' | 'dir', node: Node) => {
344
+ if (node.outside) return;
345
+ const dir = isDir(node) ? node.path : relPath.dirname(node.path);
346
+ expand(nodeId(project.id, dir));
347
+ setCreating({ dir, kind });
348
+ };
349
+
350
+ const submitNew = async (c: { dir: string; kind: 'file' | 'dir' }, raw: string): Promise<string | null> => {
351
+ const parts = raw.replace(/\\/g, '/').split('/').map((x) => x.trim());
352
+ if (parts.some((x) => !x || x === '.' || x === '..')) return t('explorer.nameInvalid');
353
+ const path = relPath.join(c.dir, parts.join('/'));
354
+ try {
355
+ await api.fsCreate(project.id, path, c.kind);
356
+ } catch (err) {
357
+ reloadDir(c.dir);
358
+ return errorMessage(errorOf(err));
359
+ }
360
+ setCreating(undefined);
361
+ reloadDir(c.dir);
362
+ reveal(path);
363
+ if (c.kind === 'file') onOpenFile(project.id, path);
364
+ record({ kind: 'create', project: project.id, paths: [path] }, relPath.basename(path));
365
+ return null;
366
+ };
367
+
368
+ const rename = (node: Node, name: string) =>
369
+ run(async () => {
370
+ if (isRoot(node) || node.outside) return;
371
+ const to = relPath.join(relPath.dirname(node.path), name);
372
+ await api.fsRename(project.id, node.path, name);
373
+ onMoved(project.id, node.path, to);
374
+ reloadDir(relPath.dirname(node.path));
375
+ record({ kind: 'rename', project: project.id, moves: [{ from: node.path, to }] }, name);
376
+ });
377
+
378
+ const actionable = (nodes: Node[]) => nodes.filter((n) => !isRoot(n) && !n.outside);
379
+
380
+ const remove = (nodes: Node[]) =>
381
+ run(async () => {
382
+ const targets = actionable(nodes);
383
+ if (!targets.length) return;
384
+ if (targets.length > 1 && !multiDeleteEnabled()) {
385
+ toast({ message: t('explorer.deleteOne') });
386
+ return;
387
+ }
388
+ const dirty = dirtyCount?.(project.id, targets.map((n) => n.path)) ?? 0;
389
+ const ok = !confirmDelete && dirty === 0 ? true : await confirm({
390
+ title: t('explorer.deleteTitle'),
391
+ body: (
392
+ <>
393
+ <p>{targets.length === 1 ? t('explorer.deleteBody', { name: targets[0]!.name }) : t('explorer.deleteMany', { count: targets.length })}</p>
394
+ {targets.length > 1 && (
395
+ <ul>
396
+ {targets.slice(0, MAX_LISTED).map((n) => <li key={n.path}>{n.name}</li>)}
397
+ {targets.length > MAX_LISTED && <li>{t('explorer.andMore', { count: targets.length - MAX_LISTED })}</li>}
398
+ </ul>
399
+ )}
400
+ {dirty > 0 && <p>{t('explorer.dirtyDelete', { count: dirty })}</p>}
401
+ </>
402
+ ),
403
+ okLabel: t('explorer.delete'),
404
+ danger: true,
405
+ });
406
+ if (!ok) return;
407
+ const parents = new Set(targets.map((n) => relPath.dirname(n.path)));
408
+ try {
409
+ for (const n of targets) {
410
+ await api.fsDelete(project.id, [n.path]);
411
+ onDeleted(project.id, n.path);
412
+ }
413
+ } finally {
414
+ for (const dir of parents) reloadDir(dir);
415
+ }
416
+ });
417
+
418
+ /** Moves `from` to `to`; an existing name asks Keep both / Replace. Returns the final path, or null when cancelled. */
419
+ const moveOne = async (from: string, to: string): Promise<string | null> => {
420
+ const finalPath = (entry: FsEntry) => relPath.join(relPath.dirname(to), entry.name);
421
+ try {
422
+ return finalPath(await api.fsMove(project.id, from, to));
423
+ } catch (err) {
424
+ if (errorOf(err).code !== 'exists') throw err;
425
+ }
426
+ const choice = await choose({
427
+ title: t('explorer.existsTitle'),
428
+ body: t('explorer.existsBody', { name: relPath.basename(to) }),
429
+ choices: [
430
+ { id: 'keepBoth', label: t('explorer.keepBoth') },
431
+ { id: 'replace', label: t('explorer.replace'), danger: true },
432
+ ],
433
+ });
434
+ if (choice !== 'keepBoth' && choice !== 'replace') return null;
435
+ return finalPath(await api.fsMove(project.id, from, to, choice));
436
+ };
437
+
438
+ const moveInto = async (froms: string[], dir: string, dropped = false) => {
439
+ if (!froms.length) return;
440
+ if (dropped && confirmDragDrop) {
441
+ const name = relPath.basename(dir) || project.name;
442
+ const body = froms.length === 1 ? t('explorer.moveBody', { name: relPath.basename(froms[0]!), target: name }) : t('explorer.moveMany', { count: froms.length, target: name });
443
+ if (!(await confirm({ title: t('explorer.moveTitle'), body }))) return;
444
+ }
445
+ const dirty = dirtyCount?.(project.id, froms) ?? 0;
446
+ if (dirty > 0 && !(await confirm({ title: t('explorer.dirtyTitle'), body: t('explorer.dirtyMove', { count: dirty }) }))) return;
447
+ const moves: Array<{ from: string; to: string }> = [];
448
+ const reload = new Set([dir]);
449
+ try {
450
+ for (const from of froms) {
451
+ const to = await moveOne(from, relPath.join(dir, relPath.basename(from)));
452
+ if (to === null) break;
453
+ onMoved(project.id, from, to);
454
+ moves.push({ from, to });
455
+ reload.add(relPath.dirname(from));
456
+ }
457
+ } finally {
458
+ for (const d of reload) reloadDir(d);
459
+ expand(nodeId(project.id, dir));
460
+ }
461
+ if (moves.length) record({ kind: 'move', project: project.id, moves }, labelOf(moves.map((m) => m.to)));
462
+ };
463
+
464
+ // The copy route names clashes "name copy.ext", "name copy 2.ext", so pasting in place works.
465
+ const copyInto = async (froms: string[], dir: string) => {
466
+ const created: string[] = [];
467
+ try {
468
+ for (const from of froms) {
469
+ const entry = await api.fsCopy(project.id, from, relPath.join(dir, relPath.basename(from)));
470
+ created.push(relPath.join(dir, entry.name));
471
+ }
472
+ } finally {
473
+ reloadDir(dir);
474
+ expand(nodeId(project.id, dir));
475
+ }
476
+ if (created.length) record({ kind: 'copy', project: project.id, paths: created }, labelOf(created));
477
+ };
478
+
479
+ const undoOp = (op: UndoOp) =>
480
+ run(async () => {
481
+ if (!undo.take(op)) return;
482
+ try {
483
+ if ('paths' in op) {
484
+ const ok = await confirm({
485
+ title: t('explorer.undoDeleteTitle'),
486
+ body: t('explorer.undoDeleteBody', { name: labelOf(op.paths) }),
487
+ okLabel: t('explorer.undo'),
488
+ danger: true,
489
+ });
490
+ if (!ok) return void undo.push(op);
491
+ for (const path of op.paths) {
492
+ await api.fsDelete(op.project, [path]);
493
+ onDeleted(op.project, path);
494
+ }
495
+ toast({ message: t('explorer.undone', { what: t(DID[op.kind], { name: labelOf(op.paths) }) }) });
496
+ } else {
497
+ const pending = [...op.moves].reverse();
498
+ try {
499
+ while (pending.length) {
500
+ const m = pending[0]!;
501
+ await api.fsMove(op.project, m.to, m.from);
502
+ onMoved(op.project, m.to, m.from);
503
+ pending.shift();
504
+ }
505
+ } catch (err) {
506
+ if (pending.length) undo.push({ ...op, moves: pending.reverse() });
507
+ throw err;
508
+ }
509
+ toast({ message: t('explorer.undone', { what: t(DID[op.kind], { name: labelOf(op.moves.map((m) => m.to)) }) }) });
510
+ }
511
+ } finally {
512
+ folderFiles.current.clear();
513
+ tree.current?.reload();
514
+ }
515
+ });
516
+
517
+ const undoLast = () => {
518
+ const op = undo.last();
519
+ if (op) void undoOp(op);
520
+ else toast({ message: t('explorer.undoNothing') });
521
+ };
522
+
523
+ const stash = (mode: 'copy' | 'cut', nodes: Node[]) => {
524
+ const paths = actionable(nodes).map((n) => n.path);
525
+ if (paths.length) clipboard.current = { mode, paths };
526
+ };
527
+
528
+ const paste = (node: Node) =>
529
+ run(async () => {
530
+ const clip = clipboard.current;
531
+ if (!clip || node.outside) return;
532
+ const dir = isDir(node) ? node.path : relPath.dirname(node.path);
533
+ if (clip.mode === 'copy') return copyInto(clip.paths, dir);
534
+ await moveInto(clip.paths.filter((from) => relPath.dirname(from) !== dir && !relPath.isUnder(dir, from)), dir);
535
+ clipboard.current = undefined;
536
+ });
537
+
538
+ const takenIn = (dir: string) => takenInDir(project.id, dir);
539
+
540
+ const extract = (node: Node, ask: boolean) =>
541
+ run(async () => {
542
+ const dir = relPath.dirname(node.path);
543
+ const { fold, taken } = await takenIn(dir);
544
+ let to = relPath.join(dir, freeName(archiveStem(node.name), true, taken, fold));
545
+ if (ask) {
546
+ const raw = await prompt({
547
+ title: t('explorer.extractTo'),
548
+ label: t('explorer.extractToLabel'),
549
+ value: to,
550
+ validate: (v) => (v.replace(/\\/g, '/').split('/').some((x) => !x.trim() || x.trim() === '.' || x.trim() === '..') ? t('explorer.nameInvalid') : null),
551
+ });
552
+ if (raw === null) return;
553
+ to = raw.replace(/\\/g, '/').split('/').map((x) => x.trim()).join('/');
554
+ }
555
+ const result = await api.fsExtract(project.id, node.path, to);
556
+ reloadDir(dir);
557
+ reloadDir(relPath.dirname(result.path));
558
+ reveal(result.path);
559
+ toast({ message: t('explorer.extracted', { name: node.name, target: result.path }) });
560
+ });
561
+
562
+ const compress = (nodes: Node[], format: FsArchiveFormat) =>
563
+ run(async () => {
564
+ const done = await compressPaths(project, actionable(nodes).map((n) => n.path), format);
565
+ if (!done) return;
566
+ reloadDir(done.dir);
567
+ reveal(done.result.path);
568
+ toast({ message: done.message });
569
+ });
570
+
571
+ /**
572
+ * Files and folders dragged in from the OS. A top-level name that is taken asks Skip / Keep both / Replace
573
+ * (once for all with the checkbox); what is inside a dropped folder is not compared. Up to UPLOAD_PARALLEL files go at once.
574
+ */
575
+ const dropFiles = (data: DataTransfer, target: Node) => {
576
+ const entries = entriesOf(data);
577
+ if (target.outside || !entries.length) return;
578
+ void run(async () => {
579
+ const dir = target.path;
580
+ const fold = /Windows|Mac/.test(navigator.userAgent) ? (n: string) => n.toLowerCase() : (n: string) => n;
581
+ const taken = new Set((await api.fsList(project.id, dir)).map((e) => fold(e.name)));
582
+ let all: 'skip' | 'keepBoth' | 'replace' | undefined;
583
+ const jobs: Array<{ name: string; tree: Awaited<ReturnType<typeof walkEntry>>; isDir: boolean; replace?: string }> = [];
584
+ for (const entry of entries) {
585
+ let name = entry.name;
586
+ let replace: string | undefined;
587
+ if (taken.has(fold(name))) {
588
+ let choice = all;
589
+ if (!choice) {
590
+ let applyAll = false;
591
+ const picked = await choose({
592
+ title: t('explorer.existsTitle'),
593
+ body: t('explorer.existsBody', { name }),
594
+ choices: [
595
+ { id: 'keepBoth', label: t('explorer.keepBoth') },
596
+ { id: 'replace', label: t('explorer.replace'), danger: true },
597
+ { id: 'skip', label: t('explorer.skip') },
598
+ ],
599
+ checkbox: entries.length > 1 ? { label: t('explorer.applyToAll'), onChange: (v) => (applyAll = v) } : undefined,
600
+ });
601
+ if (picked !== 'keepBoth' && picked !== 'replace' && picked !== 'skip') return;
602
+ choice = picked;
603
+ if (applyAll) all = choice;
604
+ }
605
+ if (choice === 'skip') continue;
606
+ if (choice === 'keepBoth') name = freeName(name, entry.isDirectory, taken, fold);
607
+ else replace = relPath.join(dir, name);
608
+ }
609
+ taken.add(fold(name));
610
+ jobs.push({ name, tree: await walkEntry(entry), isDir: entry.isDirectory, replace });
611
+ }
612
+
613
+ const under = (j: (typeof jobs)[number], rel: string[]) => [dir, j.name, ...rel.slice(1)].filter(Boolean).join('/');
614
+ const files = jobs.flatMap((j) => j.tree.files.map((f) => ({ path: under(j, f.rel), file: f.file })));
615
+ const emptyDirs = jobs.flatMap((j) => j.tree.emptyDirs.map((rel) => under(j, rel)));
616
+ let done = 0;
617
+ let failed = 0;
618
+ let firstError: unknown;
619
+ const progress = () => {
620
+ if (files.length > 1) toast({ key: 'upload', message: t('explorer.uploading', { done, total: files.length }), timeout: 0 });
621
+ };
622
+ progress();
623
+ try {
624
+ // Replaced names are removed only now, after every prompt and folder read succeeded.
625
+ for (const j of jobs) if (j.replace) await api.fsDelete(project.id, [j.replace]);
626
+ for (const path of emptyDirs) await api.fsCreate(project.id, path, 'dir').catch((err) => { if (errorOf(err).code !== 'exists') throw err; });
627
+ const queue = [...files];
628
+ await Promise.all(
629
+ Array.from({ length: Math.min(UPLOAD_PARALLEL, queue.length) }, async () => {
630
+ for (let next = queue.shift(); next; next = queue.shift()) {
631
+ try {
632
+ await api.fsUpload(project.id, next.path, next.file);
633
+ } catch (err) {
634
+ failed++;
635
+ firstError ??= err;
636
+ }
637
+ done++;
638
+ progress();
639
+ }
640
+ }),
641
+ );
642
+ } finally {
643
+ reloadDir(dir);
644
+ expand(nodeId(project.id, dir));
645
+ }
646
+ const created = jobs.map((j) => relPath.join(dir, j.name));
647
+ if (failed) {
648
+ toast({ key: 'upload', kind: 'error', message: t('explorer.uploadFailed', { count: failed, error: errorMessage(errorOf(firstError)) }) });
649
+ } else if (created.length) {
650
+ record({ kind: 'copy', project: project.id, paths: created }, labelOf(created), 'upload');
651
+ if (created.length === 1 && !jobs[0]!.isDir) onOpenFile(project.id, created[0]!);
652
+ }
653
+ });
654
+ };
655
+
656
+ // The OS fetches a dragged-out file by URL with no headers; the link is made on pointer down so it is there at dragstart.
657
+ const prepareDragOut = (node: Node) => {
658
+ if (isDir(node) || node.outside) return;
659
+ const id = nodeId(project.id, node.path);
660
+ api.fsDownloadTicket(project.id, node.path).then(
661
+ ({ ticket, name }) => (outLink.current = { id, name, url: `${window.location.origin}/api/fs/download?t=${ticket}`, at: Date.now() }),
662
+ () => {},
663
+ );
664
+ };
665
+ const dragOutLink = (node: Node) => {
666
+ const link = outLink.current;
667
+ outLink.current = undefined;
668
+ return link && link.id === nodeId(project.id, node.path) && Date.now() - link.at < LINK_FRESH_MS ? link : undefined;
669
+ };
670
+
671
+ const drop = (dragged: Node[], target: Node, copy: boolean) =>
672
+ run(async () => {
673
+ if (target.outside) return;
674
+ const nodes = actionable(dragged).filter((n) => !relPath.isUnder(target.path, n.path));
675
+ if (copy) await copyInto(nodes.map((n) => n.path), target.path);
676
+ else await moveInto(nodes.filter((n) => relPath.dirname(n.path) !== target.path).map((n) => n.path), target.path, true);
677
+ });
678
+
679
+ const selectedDir = () => {
680
+ const picked = tree.current?.selection() ?? [];
681
+ return picked.length === 1 && !picked[0]!.outside ? picked[0]! : rootNode;
682
+ };
683
+ useImperativeHandle(apiRef, () => ({
684
+ newFile: () => void create('file', selectedDir()),
685
+ newFolder: () => void create('dir', selectedDir()),
686
+ refresh: () => {
687
+ folderFiles.current.clear();
688
+ tree.current?.reload();
689
+ refreshGit();
690
+ },
691
+ collapseAll: () => onExpandedChange([nodeId(project.id, '')]),
692
+ expandAll: () => onExpandedChange([...new Set([...expanded, ...(tree.current?.loadedFolders() ?? [])])]),
693
+ canCollapse: () => expanded.some((id) => id !== nodeId(project.id, '')),
694
+ reveal,
695
+ scrollTo,
696
+ finding: () => !!find,
697
+ selectedFolder: () => {
698
+ const node = selectedDir();
699
+ return isDir(node) ? node.path : relPath.dirname(node.path);
700
+ },
701
+ }));
702
+
703
+ const menuItems = (node: Node, selection: Node[]): Array<ContextMenuItem | 'sep'> => {
704
+ const disabled = !!node.outside;
705
+ const item = (id: string, label: string, act: () => void, extra: Partial<ContextMenuItem> = {}): ContextMenuItem => ({ id, label, run: act, disabled, ...extra });
706
+ const call = (job: () => Promise<unknown>) => () => void run(async () => void (await job()));
707
+ const ref = (n: Node) => ({ project: project.id, path: n.path });
708
+ const compressItems = (nodes: Node[]) => {
709
+ const ok = nodes.length > 0 && nodes.every((n) => !isRoot(n) && !n.outside) && commonParent(nodes) !== undefined;
710
+ return ok
711
+ ? [
712
+ item('compressZip', t('explorer.compressZip'), () => void compress(nodes, 'zip'), { icon: 'archive', disabled: false }),
713
+ item('compress7z', t('explorer.compress7z'), () => void compress(nodes, '7z'), { disabled: false }),
714
+ ]
715
+ : [];
716
+ };
717
+ if (selection.length > 1) {
718
+ const pair = selection.length === 2 && selection.every((n) => !isDir(n) && !n.outside);
719
+ return [
720
+ ...(pair && onCompare ? [item('compareSelected', t('explorer.compareSelected'), () => onCompare(ref(selection[0]!), ref(selection[1]!)), { disabled: false }), 'sep' as const] : []),
721
+ item('copy', t('explorer.copy'), () => stash('copy', selection), { icon: 'copy', hint: 'Ctrl+C' }),
722
+ item('cut', t('explorer.cut'), () => stash('cut', selection), { hint: 'Ctrl+X' }),
723
+ ...compressItems(selection),
724
+ item('delete', t('explorer.delete'), () => void remove(selection), {
725
+ icon: 'trash',
726
+ hint: multiDeleteEnabled() ? 'Del' : t('explorer.deleteOne'),
727
+ danger: true,
728
+ disabled: disabled || !multiDeleteEnabled(),
729
+ }),
730
+ ];
731
+ }
732
+ return [
733
+ ...(isDir(node) ? [] : [item('open', t('explorer.open'), () => open(node), { icon: 'file' })]),
734
+ ...(isDir(node) || !onOpenFileToSide ? [] : [item('openSide', t('explorer.openSide'), () => onOpenFileToSide(project.id, node.path), { icon: 'split', hint: 'Ctrl+Enter' })]),
735
+ item('newFile', t('explorer.newFile'), () => void create('file', node), { icon: 'plus' }),
736
+ item('newDir', t('explorer.newFolder'), () => void create('dir', node), { icon: 'folder' }),
737
+ 'sep',
738
+ ...(isRoot(node) ? [] : [item('copy', t('explorer.copy'), () => stash('copy', [node]), { icon: 'copy', hint: 'Ctrl+C' }), item('cut', t('explorer.cut'), () => stash('cut', [node]), { hint: 'Ctrl+X' })]),
739
+ item('paste', t('explorer.paste'), () => void paste(node), { hint: 'Ctrl+V', disabled: disabled || !clipboard.current }),
740
+ ...(isRoot(node)
741
+ ? []
742
+ : [
743
+ item('rename', t('explorer.rename'), () => tree.current?.startRename(nodeId(project.id, node.path)), { icon: 'edit', hint: 'F2' }),
744
+ item('delete', t('explorer.delete'), () => void remove([node]), { icon: 'trash', hint: 'Del', danger: true }),
745
+ 'sep' as const,
746
+ ...(isArchive(node)
747
+ ? [
748
+ item('extractHere', t('explorer.extractHere'), () => void extract(node, false), { icon: 'archive' }),
749
+ item('extractTo', t('explorer.extractTo'), () => void extract(node, true)),
750
+ ]
751
+ : []),
752
+ ...compressItems([node]),
753
+ 'sep' as const,
754
+ ]),
755
+ item('copyFullPath', t('explorer.copyFullPath'), () => copyPath(fullPath(project.path, node.path)), { icon: 'copy', disabled: false }),
756
+ ...(isRoot(node) ? [] : [item('copyPath', t('explorer.copyPath'), () => copyPath(node.path), { icon: 'copy', disabled: false })]),
757
+ 'sep' as const,
758
+ ...(isDir(node) || !onCompare || !onSelectForCompare
759
+ ? []
760
+ : [
761
+ ...(compareWith && !(compareWith.project === project.id && compareWith.path === node.path)
762
+ ? [item('compareWithSelected', t('explorer.compareWithSelected'), () => onCompare(compareWith, ref(node)))]
763
+ : []),
764
+ item('selectForCompare', t('explorer.selectForCompare'), () => onSelectForCompare(project.id, node.path)),
765
+ 'sep' as const,
766
+ ]),
767
+ ...(isDir(node) && onOpenDetails ? [item('openDetails', t('explorer.openDetails'), () => onOpenDetails(project.id, node.path), { icon: 'table', disabled })] : []),
768
+ ...(isDir(node) ? [item('openTerminal', t('explorer.openTerminal'), () => onOpenTerminal(project.id, node.path), { icon: 'terminal', disabled })] : []),
769
+ item('reveal', t('explorer.reveal'), call(() => api.fsReveal(project.id, node.path))),
770
+ ...(editors.data ?? []).map((editor) =>
771
+ item(`editor-${editor.id}`, t('explorer.openInEditor', { editor: editor.name }), call(() => api.openInEditor(editor.id, project.id, node.path)), {
772
+ disabled: disabled || editor.path === null,
773
+ hint: editor.path === null ? t('explorer.editorMissing') : undefined,
774
+ }),
775
+ ),
776
+ ...(isDir(node) ? [] : [item('openDefault', t('explorer.openDefault'), call(() => api.fsOpen(project.id, node.path)))]),
777
+ 'sep' as const,
778
+ item('properties', t('explorer.properties'), () => setProperties(node.path)),
779
+ ];
780
+ };
781
+
782
+ return (
783
+ <section className="xp-project">
784
+ {element}
785
+ <TreeView
786
+ ref={tree}
787
+ roots={roots}
788
+ hideRoots
789
+ idOf={(n) => nodeId(n.project, n.path)}
790
+ labelOf={(n) => (n.outside ? `${n.name} (${t('explorer.outside')})` : n.name)}
791
+ iconOf={iconOf}
792
+ dimmedOf={(n) => !!n.excluded}
793
+ iconToneOf={(n) => (isDir(n) ? undefined : fileIconOf(n.name).tone)}
794
+ hasChildren={(n) => isDir(n) || !!n.nested?.length}
795
+ isFolder={isDir}
796
+ compact={compact}
797
+ visibleOf={visibleOf}
798
+ markOf={markOf}
799
+ decorationOf={decorationOf}
800
+ forceExpanded={forced}
801
+ loadChildren={loadChildren}
802
+ expanded={expanded}
803
+ onExpandedChange={onExpandedChange}
804
+ onOpen={open}
805
+ onOpenToSide={onOpenFileToSide && ((node) => !node.outside && onOpenFileToSide(project.id, node.path))}
806
+ onPreview={onPreviewFile && ((node) => !node.outside && onPreviewFile(project.id, node.path))}
807
+ onContextMenu={(node, e) => setMenu({ at: { x: e.clientX, y: e.clientY }, node, selection: tree.current?.selection() ?? [node] })}
808
+ onRename={(node, name) => void rename(node, name)}
809
+ onDelete={(nodes) => void remove(nodes)}
810
+ onCopy={(nodes) => stash('copy', nodes)}
811
+ onCut={(nodes) => stash('cut', nodes)}
812
+ onPaste={(node) => void paste(node)}
813
+ onUndo={undoLast}
814
+ newEntry={creating && { parentId: nodeId(project.id, creating.dir), kind: creating.kind, onSubmit: (name) => submitNew(creating, name), onCancel: () => setCreating(undefined) }}
815
+ draggable
816
+ onDrop={(dragged, target, copy) => void drop(dragged, target, copy)}
817
+ onDropFiles={dropFiles}
818
+ dragOut={{ prepare: prepareDragOut, link: dragOutLink }}
819
+ />
820
+ {menu && <ContextMenu at={menu.at} items={menuItems(menu.node, menu.selection)} onClose={() => setMenu(undefined)} />}
821
+ {properties !== undefined && <PropertiesDialog root={project.id} path={properties} onClose={() => setProperties(undefined)} />}
822
+ </section>
823
+ );
824
+ }