main
js 1,599 lines 50 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi, fetchApi } from "/js/api.js";
3 import { formatDateTime } from "/js/time-utils.js";
4 import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
5 import {
6 openLatest as openLatestSurface,
7 setupFloatingSurfaceModalChrome,
8 } from "/js/surfaces.js";
9
10 const FILE_BROWSER_MODAL_PATH = "modals/file-browser/file-browser.html";
11 const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
12 const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
13 const PICKER_MODE_NONE = "";
14 const PICKER_MODE_TEXT_OPEN = "text-open";
15 const PICKER_MODE_SAVE_AS = "save-as";
16 const EDITOR_TEXT_EXTENSIONS = new Set(["md", "txt"]);
17 const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
18 const BROWSER_EXTENSIONS = new Set([
19 "html",
20 "htm",
21 "xhtml",
22 "svg",
23 "xml",
24 "pdf",
25 "png",
26 "jpg",
27 "jpeg",
28 "gif",
29 "webp",
30 "bmp",
31 "ico",
32 ]);
33 const ARCHIVE_SUFFIXES = [".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar", ".tgz", ".tbz", ".tbz2", ".txz", ".zip", ".rar", ".7z", ".gz", ".bz2", ".xz", ".zst"];
34
35 const SURFACE_ACTIONS = {
36 editor: {
37 label: "Open in Editor",
38 icon: "article",
39 title: "Open text in Editor",
40 },
41 desktop: {
42 label: "Open in Desktop",
43 icon: "desktop_windows",
44 title: "Open document in Desktop",
45 },
46 browser: {
47 label: "Open in Browser",
48 icon: "language",
49 title: "Open web-viewable file in Browser",
50 },
51 };
52
53 function delay(ms) {
54 return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
55 }
56
57 // Model migrated from legacy file_browser.js (lift-and-shift)
58 const model = {
59 // Reactive state
60 isLoading: false,
61 browser: {
62 title: "File Browser",
63 currentPath: "",
64 entries: [],
65 parentPath: "",
66 sortBy: "name",
67 sortDirection: "asc",
68 },
69 history: [], // navigation stack
70 initialPath: "", // Store path for open() call
71 closePromise: null,
72 isSurfaceHandoff: false,
73 surfaceHandoffPath: "",
74 error: null,
75 pathInput: "",
76 pathError: "",
77 isPathSubmitting: false,
78 rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
79 settingsLoadPromise: null,
80 settingsUpdatedHandler: null,
81 _floatingCleanup: null,
82 _mountedDefaultLoadTimer: null,
83 renameTarget: null,
84 renameName: "",
85 renameMode: "rename",
86 isRenaming: false,
87 renameError: null,
88 renameAfterConfirm: null,
89 renamePerformAction: null,
90 renameValidateName: null,
91 openDropdownPath: null, // Track which dropdown is currently open
92 dropdownStyle: {},
93 searchQuery: "",
94 isBulkBusy: false,
95 draggedPaths: [],
96 dragOverPath: "",
97 pickerMode: PICKER_MODE_NONE,
98 pickerConfirmLabel: "",
99 pickerFilename: "",
100 pickerDefaultExtension: "md",
101 pickerFilenameError: "",
102 pickerOnConfirm: null,
103
104 // --- Lifecycle -----------------------------------------------------------
105 init() {
106 if (this.settingsUpdatedHandler) return;
107 this.settingsUpdatedHandler = (event) => {
108 const value = event?.detail?.file_browser_remember_last_directory;
109 if (typeof value !== "boolean") return;
110 this.rememberLastDirectory = value;
111 if (!value) this.clearRememberedDirectory();
112 };
113 document.addEventListener("settings-updated", this.settingsUpdatedHandler);
114 },
115
116 onMount(element = null, options = {}) {
117 this._floatingCleanup?.();
118 this._floatingCleanup = null;
119 const mode = options?.mode === "canvas" ? "canvas" : "modal";
120 if (mode === "modal") {
121 this.setupFloatingModal(element);
122 } else {
123 this.scheduleMountedDefaultLoad();
124 }
125 },
126
127 onUnmount() {
128 this._floatingCleanup?.();
129 this._floatingCleanup = null;
130 this.cancelMountedDefaultLoad();
131 },
132
133 // --- Public API (called from button/link) --------------------------------
134 async open(path = "", options = {}) {
135 if (this.isLoading) return; // Prevent double-open
136 this.resetOpenState(options);
137
138 try {
139 // Open modal FIRST (immediate UI feedback)
140 this.closePromise = window.openModal(FILE_BROWSER_MODAL_PATH);
141 await this.loadOpeningPath(path);
142
143 // await modal close
144 await this.closePromise;
145 if (!this.isSurfaceHandoff) this.destroy();
146
147 } catch (error) {
148 console.error("File browser error:", error);
149 this.error = error?.message || "Failed to load files";
150 this.isLoading = false;
151 }
152 },
153
154 async openSurface(path = "") {
155 if (this.isLoading) return false;
156 this.resetOpenState();
157
158 try {
159 const retainedPath = this.normalizeOpeningPath(
160 path
161 || this.surfaceHandoffPath
162 || this.browser.currentPath
163 || this.initialPath
164 );
165 return await this.loadOpeningPath(retainedPath);
166 } catch (error) {
167 console.error("File browser surface error:", error);
168 this.error = error?.message || "Failed to load files";
169 this.isLoading = false;
170 return false;
171 }
172 },
173
174 handleClose() {
175 // Close the modal manually
176 this.disposeScopedTooltips();
177 window.closeModal(FILE_BROWSER_MODAL_PATH);
178 },
179
180 async openTextPicker(path = "", onConfirm = null) {
181 return await this.open(path, {
182 pickerMode: PICKER_MODE_TEXT_OPEN,
183 confirmLabel: "Open Selected",
184 onConfirm,
185 });
186 },
187
188 async openSaveAsPicker(path = "", options = {}) {
189 return await this.open(path, {
190 pickerMode: PICKER_MODE_SAVE_AS,
191 confirmLabel: "Save Here",
192 filename: options.filename || "Untitled.md",
193 defaultExtension: options.defaultExtension || "",
194 onConfirm: options.onConfirm,
195 });
196 },
197
198 destroy() {
199 this._floatingCleanup?.();
200 this._floatingCleanup = null;
201 this.cancelMountedDefaultLoad();
202 // Reset state when modal closes
203 this.isLoading = false;
204 this.history = [];
205 this.initialPath = "";
206 this.closePromise = null;
207 this.isSurfaceHandoff = false;
208 this.surfaceHandoffPath = "";
209 this.browser.currentPath = "";
210 this.browser.parentPath = "";
211 this.browser.entries = [];
212 this.openDropdownPath = null;
213 this.searchQuery = "";
214 this.isBulkBusy = false;
215 this.clearDragState();
216 this.pathInput = "";
217 this.pathError = "";
218 this.isPathSubmitting = false;
219 this.resetPickerState();
220 this.resetRenameState();
221 },
222
223 setupFloatingModal(element = null) {
224 this._floatingCleanup?.();
225 this._floatingCleanup = setupFloatingSurfaceModalChrome({
226 root: element,
227 modalClass: "file-browser-modal",
228 focusButtonClass: "file-browser-modal-focus-button",
229 minWidth: 420,
230 minHeight: 360,
231 });
232 },
233
234 cancelMountedDefaultLoad() {
235 if (!this._mountedDefaultLoadTimer) return;
236 globalThis.clearTimeout(this._mountedDefaultLoadTimer);
237 this._mountedDefaultLoadTimer = null;
238 },
239
240 scheduleMountedDefaultLoad() {
241 this.cancelMountedDefaultLoad();
242 this._mountedDefaultLoadTimer = globalThis.setTimeout(async () => {
243 this._mountedDefaultLoadTimer = null;
244 if (this.isLoading) return;
245 const targetPath = this.browser.currentPath || "";
246 if (targetPath && this.browser.entries.length) {
247 this.syncPathInput();
248 return;
249 }
250 try {
251 await this.loadOpeningPath(targetPath);
252 } catch (error) {
253 console.error("File browser default path load failed:", error);
254 }
255 }, 120);
256 },
257
258 // --- Helpers -------------------------------------------------------------
259 resetOpenState(options = {}) {
260 this.cancelMountedDefaultLoad();
261 this.isLoading = true;
262 this.error = null;
263 this.history = [];
264 this.searchQuery = "";
265 this.isBulkBusy = false;
266 this.clearDragState();
267 this.pathError = "";
268 this.isPathSubmitting = false;
269 this.configurePicker(options);
270 },
271
272 configurePicker(options = {}) {
273 const mode = String(options?.pickerMode || PICKER_MODE_NONE).trim();
274 this.pickerMode = [PICKER_MODE_TEXT_OPEN, PICKER_MODE_SAVE_AS].includes(mode)
275 ? mode
276 : PICKER_MODE_NONE;
277 this.pickerConfirmLabel = String(options?.confirmLabel || "").trim()
278 || (this.pickerMode === PICKER_MODE_SAVE_AS ? "Save Here" : "Open Selected");
279 this.pickerFilename = String(options?.filename || "").trim();
280 this.pickerDefaultExtension = this.normalizedEditorTextExtension(
281 options?.defaultExtension || this.fileExtension({ name: this.pickerFilename }) || "md",
282 );
283 this.pickerFilenameError = "";
284 this.pickerOnConfirm = typeof options?.onConfirm === "function" ? options.onConfirm : null;
285 if (this.pickerMode) this.clearSelection();
286 },
287
288 resetPickerState() {
289 this.pickerMode = PICKER_MODE_NONE;
290 this.pickerConfirmLabel = "";
291 this.pickerFilename = "";
292 this.pickerDefaultExtension = "md";
293 this.pickerFilenameError = "";
294 this.pickerOnConfirm = null;
295 },
296
297 async loadOpeningPath(path = "") {
298 await this.loadDirectoryPreference();
299 const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
300 const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
301 const targetPath = explicitPath || rememberedPath || "$WORK_DIR";
302 this.browser.currentPath = targetPath;
303 this.syncPathInput();
304
305 const loaded = await this.fetchFiles(this.browser.currentPath, {
306 preserveOnError: Boolean(rememberedPath && targetPath === rememberedPath),
307 suppressErrorToast: Boolean(rememberedPath && targetPath === rememberedPath),
308 });
309 if (!loaded && rememberedPath && targetPath === rememberedPath) {
310 this.clearRememberedDirectory();
311 return await this.fetchFiles("$WORK_DIR");
312 }
313 return loaded;
314 },
315
316 beginSurfaceHandoff() {
317 this.isSurfaceHandoff = true;
318 this.surfaceHandoffPath = this.browser.currentPath || this.pathInput || "";
319 },
320
321 finishSurfaceHandoff() {
322 this.isSurfaceHandoff = false;
323 this.surfaceHandoffPath = "";
324 },
325
326 cancelSurfaceHandoff() {
327 this.isSurfaceHandoff = false;
328 this.surfaceHandoffPath = "";
329 },
330
331 isArchive(filename) {
332 return ARCHIVE_SUFFIXES.some((suffix) => String(filename || "").toLowerCase().endsWith(suffix));
333 },
334
335 saveScrollPosition() {
336 // Find the file browser modal's scrollable container
337 // We look for the modal containing .file-browser-root to target the correct modal
338 const fileBrowserRoot = document.querySelector('.file-browser-root');
339 if (fileBrowserRoot) {
340 const modalScroll = fileBrowserRoot.closest('.modal-scroll');
341 if (modalScroll) {
342 return {
343 scrollTop: modalScroll.scrollTop,
344 scrollLeft: modalScroll.scrollLeft
345 };
346 }
347 }
348 return null;
349 },
350
351 restoreScrollPosition(scrollPos) {
352 if (!scrollPos) return;
353
354 const restore = () => {
355 const fileBrowserRoot = document.querySelector('.file-browser-root');
356 if (fileBrowserRoot) {
357 const modalScroll = fileBrowserRoot.closest('.modal-scroll');
358 if (modalScroll) {
359 modalScroll.scrollTop = scrollPos.scrollTop;
360 modalScroll.scrollLeft = scrollPos.scrollLeft;
361 }
362 }
363 };
364
365 requestAnimationFrame(() => requestAnimationFrame(restore));
366 },
367
368 formatFileSize(size) {
369 if (size === 0) return "0 Bytes";
370 const k = 1024;
371 const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
372 const i = Math.floor(Math.log(size) / Math.log(k));
373 return parseFloat((size / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
374 },
375
376 formatDate(dateString) {
377 return formatDateTime(dateString, "short");
378 },
379
380 decorateEntries(entries = [], selectedPaths = new Set()) {
381 return entries.map((entry) => ({
382 ...entry,
383 selected: selectedPaths.has(entry.path),
384 }));
385 },
386
387 get filteredEntries() {
388 const query = this.searchQuery.trim().toLowerCase();
389 return this.browser.entries.filter((file) => {
390 if (!this.pickerAllowsEntry(file)) return false;
391 if (!query) return true;
392 const searchable = [
393 file.name,
394 file.path,
395 file.type,
396 file.symlink_target,
397 file.is_dir ? "folder directory" : "file",
398 ]
399 .filter(Boolean)
400 .join(" ")
401 .toLowerCase();
402 return searchable.includes(query);
403 });
404 },
405
406 get visibleEntries() {
407 return this.sortFiles(this.filteredEntries);
408 },
409
410 clearSearch() {
411 this.searchQuery = "";
412 },
413
414 get selectedFiles() {
415 return this.browser.entries.filter((file) => file.selected && this.isSelectableEntry(file));
416 },
417
418 get selectableEntries() {
419 return this.filteredEntries.filter((file) => this.isSelectableEntry(file));
420 },
421
422 get selectedCount() {
423 return this.selectedFiles.length;
424 },
425
426 get selectedCountLabel() {
427 return `${this.selectedCount} ${this.selectedCount === 1 ? "item" : "items"} selected`;
428 },
429
430 get allVisibleSelected() {
431 return (
432 this.selectableEntries.length > 0 &&
433 this.selectableEntries.every((file) => file.selected)
434 );
435 },
436
437 get someVisibleSelected() {
438 return this.selectableEntries.some((file) => file.selected);
439 },
440
441 toggleSelectAllVisible() {
442 const shouldSelect = !this.allVisibleSelected;
443 this.selectableEntries.forEach((file) => {
444 file.selected = shouldSelect;
445 });
446 },
447
448 clearSelection() {
449 this.browser.entries.forEach((file) => {
450 file.selected = false;
451 });
452 },
453
454 isPickerMode() {
455 return this.pickerMode !== PICKER_MODE_NONE;
456 },
457
458 isTextOpenPicker() {
459 return this.pickerMode === PICKER_MODE_TEXT_OPEN;
460 },
461
462 isSaveAsPicker() {
463 return this.pickerMode === PICKER_MODE_SAVE_AS;
464 },
465
466 isSelectableEntry(file = {}) {
467 if (this.isSaveAsPicker()) return false;
468 if (this.isTextOpenPicker()) return !file?.is_dir && this.fileSurfaceTarget(file) === "editor";
469 return true;
470 },
471
472 normalizeOpeningPath(path) {
473 return String(path || "").trim();
474 },
475
476 normalizeSubmittedPath(path) {
477 const trimmed = String(path || "").trim();
478 if (!trimmed || trimmed === "$WORK_DIR") return trimmed;
479 return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
480 },
481
482 syncPathInput() {
483 this.pathInput = this.browser.currentPath || "";
484 },
485
486 resetPathInput() {
487 this.syncPathInput();
488 this.pathError = "";
489 },
490
491 async loadDirectoryPreference() {
492 if (this.settingsLoadPromise) return await this.settingsLoadPromise;
493
494 this.settingsLoadPromise = (async () => {
495 try {
496 const response = await callJsonApi("settings_get", null);
497 const remember = response?.settings?.file_browser_remember_last_directory;
498 this.rememberLastDirectory =
499 typeof remember === "boolean" ? remember : DEFAULT_REMEMBER_LAST_DIRECTORY;
500 } catch (error) {
501 console.warn("Failed to load file browser directory preference:", error);
502 this.rememberLastDirectory = DEFAULT_REMEMBER_LAST_DIRECTORY;
503 } finally {
504 if (!this.rememberLastDirectory) this.clearRememberedDirectory();
505 this.settingsLoadPromise = null;
506 }
507 return this.rememberLastDirectory;
508 })();
509
510 return await this.settingsLoadPromise;
511 },
512
513 getRememberedDirectory() {
514 if (!this.rememberLastDirectory) return "";
515 try {
516 return localStorage.getItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY) || "";
517 } catch {
518 return "";
519 }
520 },
521
522 rememberCurrentDirectory(path = this.browser.currentPath) {
523 if (!this.rememberLastDirectory) return;
524 const directory = this.normalizeOpeningPath(path);
525 if (!directory || directory === "$WORK_DIR") return;
526 try {
527 localStorage.setItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY, directory);
528 } catch {}
529 },
530
531 clearRememberedDirectory() {
532 try {
533 localStorage.removeItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY);
534 } catch {}
535 },
536
537 disposeScopedTooltips() {
538 const root = document.querySelector(".file-browser-root");
539 const tooltipApi = globalThis.bootstrap?.Tooltip;
540 if (!root || !tooltipApi) return;
541
542 root.querySelectorAll("[data-bs-tooltip-initialized]").forEach((element) => {
543 const instance = tooltipApi.getInstance(element);
544 try {
545 instance?.dispose();
546 } catch {}
547 });
548 document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove());
549 },
550
551 // --- Modal helpers -------------------------------------------------------
552 normalizePath(path) {
553 if (!path) return "";
554 return path.startsWith("/") ? path : `/${path}`;
555 },
556
557 fileExtension(file = {}) {
558 const name = String(file?.name || file?.path || "").split(/[?#]/, 1)[0].toLowerCase();
559 const index = name.lastIndexOf(".");
560 return index >= 0 ? name.slice(index + 1) : "";
561 },
562
563 fileSurfaceTarget(file = {}) {
564 if (!file || file.is_dir) return "";
565 const ext = this.fileExtension(file);
566 if (EDITOR_TEXT_EXTENSIONS.has(ext)) return "editor";
567 if (BROWSER_EXTENSIONS.has(ext)) return "browser";
568 if (DESKTOP_EXTENSIONS.has(ext)) return "desktop";
569 return "";
570 },
571
572 pickerAllowsEntry(file = {}) {
573 if (!this.isTextOpenPicker()) return true;
574 return Boolean(file?.is_dir || this.fileSurfaceTarget(file) === "editor");
575 },
576
577 pickerSelectedFiles() {
578 if (!this.isTextOpenPicker()) return [];
579 return this.selectedFiles.filter((file) => !file.is_dir && this.fileSurfaceTarget(file) === "editor");
580 },
581
582 pickerSelectionLabel() {
583 if (!this.isTextOpenPicker()) return "";
584 const count = this.pickerSelectedFiles().length;
585 if (!count) return "No text files selected";
586 return `${count} text ${count === 1 ? "file" : "files"} selected`;
587 },
588
589 normalizedEditorTextExtension(value = "") {
590 const ext = String(value || "").toLowerCase().trim().replace(/^\./, "");
591 return EDITOR_TEXT_EXTENSIONS.has(ext) ? ext : "md";
592 },
593
594 pickerFilenameValue() {
595 const raw = String(this.pickerFilename || "").trim();
596 if (!raw) return "";
597 const ext = this.fileExtension({ name: raw });
598 return ext ? raw : `${raw}.${this.pickerDefaultExtension || "md"}`;
599 },
600
601 validatePickerFilename(updateError = true) {
602 if (!this.isSaveAsPicker()) return true;
603 const raw = String(this.pickerFilename || "").trim();
604 const filename = this.pickerFilenameValue();
605 let error = "";
606 if (!raw) {
607 error = "File name is required.";
608 } else if (raw === "." || raw === "..") {
609 error = "File name cannot be '.' or '..'.";
610 } else if (raw.includes("/") || raw.includes("\\")) {
611 error = "File name cannot include path separators.";
612 } else if (!EDITOR_TEXT_EXTENSIONS.has(this.fileExtension({ name: filename }))) {
613 error = "Use a .md or .txt file name.";
614 } else if ((this.browser.entries || []).some((entry) => entry?.name === filename)) {
615 error = `An item named "${filename}" already exists.`;
616 }
617 if (updateError) this.pickerFilenameError = error;
618 return !error;
619 },
620
621 onPickerFilenameInput() {
622 if (this.pickerFilenameError) this.validatePickerFilename(true);
623 },
624
625 canConfirmPicker() {
626 if (this.isTextOpenPicker()) return this.pickerSelectedFiles().length > 0;
627 if (this.isSaveAsPicker()) return Boolean(this.pickerFilenameValue()) && !this.pickerFilenameError;
628 return false;
629 },
630
631 pickerTargetPath() {
632 if (!this.isSaveAsPicker()) return "";
633 return this.buildChildPath(this.pickerFilenameValue());
634 },
635
636 togglePickerFile(file = {}) {
637 if (!this.isTextOpenPicker() || file?.is_dir || this.fileSurfaceTarget(file) !== "editor") return;
638 file.selected = !file.selected;
639 },
640
641 async confirmPicker() {
642 if (!this.isPickerMode() || this.isBulkBusy) return;
643 if (this.isSaveAsPicker() && !this.validatePickerFilename(true)) return;
644 const payload = this.isSaveAsPicker()
645 ? {
646 mode: this.pickerMode,
647 directory: this.browser.currentPath,
648 filename: this.pickerFilenameValue(),
649 path: this.pickerTargetPath(),
650 }
651 : {
652 mode: this.pickerMode,
653 directory: this.browser.currentPath,
654 selectedFiles: this.pickerSelectedFiles(),
655 };
656 try {
657 this.isBulkBusy = true;
658 const result = await this.pickerOnConfirm?.(payload);
659 if (result === false) return;
660 this.disposeScopedTooltips();
661 window.closeModal(FILE_BROWSER_MODAL_PATH);
662 } catch (error) {
663 const message = error?.message || "File selection failed";
664 if (this.isSaveAsPicker()) this.pickerFilenameError = message;
665 window.toastFrontendError?.(message, "File Browser");
666 } finally {
667 this.isBulkBusy = false;
668 }
669 },
670
671 cancelPicker() {
672 this.disposeScopedTooltips();
673 window.closeModal(FILE_BROWSER_MODAL_PATH);
674 },
675
676 handleFileNameClick(file = {}) {
677 if (file?.is_dir) {
678 return this.navigateToFolder(file.path);
679 }
680 if (this.isTextOpenPicker()) {
681 this.togglePickerFile(file);
682 }
683 },
684
685 canOpenInSurface(file = {}) {
686 return Boolean(this.fileSurfaceTarget(file));
687 },
688
689 isEditorSurface(file = {}) {
690 return this.fileSurfaceTarget(file) === "editor";
691 },
692
693 canOpenInActionMenu(file = {}) {
694 const target = this.fileSurfaceTarget(file);
695 return Boolean(target && target !== "editor");
696 },
697
698 surfaceAction(file = {}) {
699 const target = this.fileSurfaceTarget(file);
700 return target ? SURFACE_ACTIONS[target] : null;
701 },
702
703 surfaceActionLabel(file = {}) {
704 return this.surfaceAction(file)?.label || "Open";
705 },
706
707 surfaceActionIcon(file = {}) {
708 return this.surfaceAction(file)?.icon || "open_in_new";
709 },
710
711 surfaceActionTitle(file = {}) {
712 return this.surfaceAction(file)?.title || "Open file";
713 },
714
715 fileUrl(file = {}) {
716 const path = this.normalizePath(String(file?.path || ""));
717 const encodedPath = path
718 .split("/")
719 .map((part) => encodeURIComponent(part))
720 .join("/");
721 return `file://${encodedPath}`;
722 },
723
724 storeHasPath(surfaceStore = {}, path = "") {
725 const normalizedPath = this.normalizePath(path);
726 const activePath = surfaceStore?.session?.path || surfaceStore?.session?.document?.path || "";
727 return this.normalizePath(activePath) === normalizedPath;
728 },
729
730 buildChildPath(name) {
731 const base = this.normalizePath(this.browser.currentPath || "");
732 const trimmedBase = base.replace(/\/$/, "");
733 if (!trimmedBase) return `/${name}`;
734 return `${trimmedBase}/${name}`;
735 },
736
737 parentPath(path) {
738 const normalized = this.normalizePath(String(path || "")).replace(/\/+$/, "");
739 const index = normalized.lastIndexOf("/");
740 if (index <= 0) return "/";
741 return normalized.slice(0, index);
742 },
743
744 siblingPath(path, name) {
745 const parent = this.parentPath(path);
746 return parent === "/" ? `/${name}` : `${parent}/${name}`;
747 },
748
749 resetRenameState() {
750 this.renameTarget = null;
751 this.renameName = "";
752 this.renameMode = "rename";
753 this.isRenaming = false;
754 this.renameError = null;
755 this.renameAfterConfirm = null;
756 this.renamePerformAction = null;
757 this.renameValidateName = null;
758 },
759
760 // --- Sorting -------------------------------------------------------------
761 toggleSort(column) {
762 if (this.browser.sortBy === column) {
763 this.browser.sortDirection =
764 this.browser.sortDirection === "asc" ? "desc" : "asc";
765 } else {
766 this.browser.sortBy = column;
767 this.browser.sortDirection = "asc";
768 }
769 },
770
771 sortFiles(entries) {
772 return [...entries].sort((a, b) => {
773 // Folders first
774 if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
775 const dir = this.browser.sortDirection === "asc" ? 1 : -1;
776 switch (this.browser.sortBy) {
777 case "name":
778 return dir * a.name.localeCompare(b.name);
779 case "size":
780 return dir * (a.size - b.size);
781 case "date":
782 return dir * (new Date(a.modified) - new Date(b.modified));
783 default:
784 return 0;
785 }
786 });
787 },
788
789 // --- Dropdown Management -------------------------------------------------
790 toggleDropdown(filePath, triggerElement = null) {
791 // Toggle: if already open, close it; otherwise open this one (closing any other)
792 if (this.openDropdownPath === filePath) {
793 this.closeDropdown();
794 return;
795 }
796 this.openDropdownPath = filePath;
797 this.dropdownStyle = this.getDropdownStyle(triggerElement);
798 },
799
800 isDropdownOpen(filePath) {
801 return this.openDropdownPath === filePath;
802 },
803
804 closeDropdown() {
805 this.openDropdownPath = null;
806 this.dropdownStyle = {};
807 },
808
809 getDropdownStyle(triggerElement) {
810 if (!triggerElement) return {};
811
812 const rect = triggerElement.getBoundingClientRect();
813 const gap = 6;
814 const padding = 8;
815 const minWidth = 180;
816 const spaceBelow = window.innerHeight - rect.bottom - gap - padding;
817 const spaceAbove = rect.top - gap - padding;
818 const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
819 const maxHeight = Math.max(96, openUp ? spaceAbove : spaceBelow);
820 const maxLeft = Math.max(padding, window.innerWidth - minWidth - padding);
821 const left = Math.min(Math.max(rect.right - minWidth, padding), maxLeft);
822
823 return {
824 position: "fixed",
825 left: `${Math.round(left)}px`,
826 right: "auto",
827 top: openUp ? "auto" : `${Math.round(rect.bottom + gap)}px`,
828 bottom: openUp ? `${Math.round(window.innerHeight - rect.top + gap)}px` : "auto",
829 minWidth: `${minWidth}px`,
830 maxHeight: `${Math.round(maxHeight)}px`,
831 zIndex: "6000",
832 };
833 },
834
835 // --- Navigation ----------------------------------------------------------
836 async fetchFiles(path = "", options = {}) {
837 const preserveOnError = options?.preserveOnError === true;
838 const suppressErrorToast = options?.suppressErrorToast === true;
839 const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";
840 this.isLoading = true;
841
842 // Preserve scroll position if refreshing the same path
843 const isSamePath =
844 this.browser.currentPath === requestedPath ||
845 (requestedPath === "$WORK_DIR" && ["/a0", "$WORK_DIR", ""].includes(this.browser.currentPath));
846 const scrollPos = isSamePath ? this.saveScrollPosition() : null;
847 const selectedPaths = isSamePath
848 ? new Set(this.selectedFiles.map((file) => file.path))
849 : new Set();
850
851 try {
852 const response = await fetchApi(
853 `/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`
854 );
855 const data = await response.json().catch(() => ({}));
856
857 const result = data.data || {};
858 const entries = result.entries || [];
859 const resolvedCurrentPath =
860 result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath);
861 const resultError =
862 data.error ||
863 result.error ||
864 (
865 requestedPath &&
866 requestedPath !== "$WORK_DIR" &&
867 !result.current_path &&
868 !entries.length
869 ? "Directory not found or not accessible"
870 : ""
871 );
872
873 if (response.ok && !resultError) {
874 if (!isSamePath) this.searchQuery = "";
875 this.browser.entries = this.decorateEntries(
876 entries,
877 selectedPaths
878 );
879 this.browser.currentPath = resolvedCurrentPath;
880 this.browser.parentPath = result.parent_path;
881 this.syncPathInput();
882 this.pathError = "";
883 this.rememberCurrentDirectory(this.browser.currentPath);
884
885 // Set isLoading to false BEFORE restoring scroll to avoid reactivity issues
886 this.isLoading = false;
887
888 // Restore scroll position if on same path
889 if (scrollPos) {
890 this.restoreScrollPosition(scrollPos);
891 }
892 return true;
893 } else {
894 const msg = resultError || "Error fetching files";
895 console.error("Error fetching files:", msg);
896 if (!preserveOnError) this.browser.entries = [];
897 this.isLoading = false;
898 if (!suppressErrorToast) window.toastFrontendError(msg, "File Browser Error");
899 return false;
900 }
901 } catch (e) {
902 const message = "Error fetching files: " + e.message;
903 if (!suppressErrorToast) {
904 window.toastFrontendError(message, "File Browser Error");
905 }
906 if (!preserveOnError) this.browser.entries = [];
907 this.isLoading = false;
908 return false;
909 }
910 },
911
912 async navigateToFolder(path) {
913 if(!path.startsWith("/")) path = "/" + path;
914 if (this.browser.currentPath !== path)
915 this.history.push(this.browser.currentPath);
916 await this.fetchFiles(path);
917 },
918
919 async submitPath() {
920 if (this.isPathSubmitting || this.isLoading) return;
921
922 const path = this.normalizeSubmittedPath(this.pathInput);
923 if (!path) {
924 this.pathError = "Enter a directory path.";
925 return;
926 }
927
928 this.isPathSubmitting = true;
929 this.pathError = "";
930
931 try {
932 const previousPath = this.browser.currentPath;
933 const loaded = await this.fetchFiles(path, {
934 preserveOnError: true,
935 suppressErrorToast: true,
936 });
937
938 if (loaded) {
939 if (previousPath && previousPath !== this.browser.currentPath) {
940 this.history.push(previousPath);
941 }
942 return;
943 }
944
945 this.pathError = "Directory not found or not accessible.";
946 } finally {
947 this.isPathSubmitting = false;
948 }
949 },
950
951 async navigateUp() {
952 if (this.browser.parentPath) {
953 this.history.push(this.browser.currentPath);
954 await this.fetchFiles(this.browser.parentPath);
955 }
956 },
957
958 // --- Drag and drop ------------------------------------------------------
959 startDrag(file = {}, event) {
960 if (this.isPickerMode() || this.isBulkBusy || !file?.path || !event?.dataTransfer) {
961 event?.preventDefault();
962 return;
963 }
964 this.draggedPaths = file.selected
965 ? this.selectedFiles.map((entry) => entry.path)
966 : [file.path];
967 event.dataTransfer.effectAllowed = "move";
968 event.dataTransfer.setData("application/x-agent-zero-files", JSON.stringify(this.draggedPaths));
969 event.dataTransfer.setData("text/plain", this.draggedPaths.join("\n"));
970 this.closeDropdown();
971 },
972
973 isDraggingPath(path = "") {
974 return this.draggedPaths.includes(path);
975 },
976
977 canDropAt(destinationPath = "") {
978 const destination = this.normalizePath(destinationPath).replace(/\/+$/, "") || "/";
979 return Boolean(this.draggedPaths.length && this.draggedPaths.every((path) => {
980 const source = this.normalizePath(path).replace(/\/+$/, "") || "/";
981 return destination !== source && !destination.startsWith(`${source}/`);
982 }));
983 },
984
985 setDropTarget(destinationPath, event) {
986 if (!this.canDropAt(destinationPath)) {
987 if (event?.dataTransfer) event.dataTransfer.dropEffect = "none";
988 return;
989 }
990 event.preventDefault();
991 event.dataTransfer.dropEffect = "move";
992 this.dragOverPath = destinationPath;
993 },
994
995 clearDropTarget(destinationPath, event) {
996 if (event?.currentTarget?.contains(event.relatedTarget)) return;
997 if (this.dragOverPath === destinationPath) this.dragOverPath = "";
998 },
999
1000 clearDragState() {
1001 this.draggedPaths = [];
1002 this.dragOverPath = "";
1003 },
1004
1005 async dropItems(destinationPath, destinationName, event) {
1006 if (!this.canDropAt(destinationPath)) return;
1007 event.preventDefault();
1008 const paths = [...this.draggedPaths];
1009 const selectedPaths = new Set(this.selectedFiles.map((file) => file.path));
1010 this.clearDragState();
1011 this.isBulkBusy = true;
1012
1013 try {
1014 const resp = await fetchApi("/rename_work_dir_file", {
1015 method: "POST",
1016 headers: { "Content-Type": "application/json" },
1017 body: JSON.stringify({
1018 action: "move",
1019 paths,
1020 destinationPath,
1021 currentPath: this.browser.currentPath,
1022 }),
1023 });
1024 const data = await resp.json().catch(() => ({}));
1025 if (!resp.ok || data.error) throw new Error(data.error || "Move failed");
1026
1027 this.browser.entries = this.decorateEntries(data.data?.entries || [], selectedPaths);
1028 this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
1029 this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
1030 const count = paths.length;
1031 window.toastFrontendSuccess(
1032 `Moved ${count} ${count === 1 ? "item" : "items"} to ${destinationName}`,
1033 "Files Moved"
1034 );
1035 } catch (error) {
1036 window.toastFrontendError(error?.message || "Move failed", "Move Error");
1037 } finally {
1038 this.isBulkBusy = false;
1039 }
1040 },
1041
1042 // --- Rename / Create -----------------------------------------------------
1043 async openRenameModal(file, options = {}) {
1044 this.resetRenameState();
1045 this.renameTarget = file;
1046 this.renameName = file?.name || "";
1047 this.renameMode = "rename";
1048 this.renameError = null;
1049 this.renameAfterConfirm = typeof options.onRenamed === "function" ? options.onRenamed : null;
1050 this.renamePerformAction = typeof options.performRename === "function" ? options.performRename : null;
1051 this.renameValidateName = typeof options.validateName === "function" ? options.validateName : null;
1052 if (typeof options.currentPath === "string" && options.currentPath) {
1053 this.browser.currentPath = options.currentPath;
1054 }
1055 if (Array.isArray(options.entries)) {
1056 this.browser.entries = options.entries;
1057 }
1058 window.openModal("modals/file-browser/rename-modal.html");
1059 },
1060
1061 async openNewFolderModal() {
1062 this.resetRenameState();
1063 this.renameMode = "create-folder";
1064 this.renameName = "";
1065 this.renameError = null;
1066 window.openModal("modals/file-browser/rename-modal.html");
1067 },
1068
1069 closeRenameModal() {
1070 window.closeModal("modals/file-browser/rename-modal.html");
1071 },
1072
1073 async confirmRename() {
1074 if (this.isRenaming) return;
1075
1076 const newName = this.renameName.trim();
1077 if (!newName) {
1078 this.renameError = "Name is required.";
1079 return;
1080 }
1081 if (newName === "." || newName === "..") {
1082 this.renameError = "Name cannot be '.' or '..'.";
1083 return;
1084 }
1085 if (newName.includes("/") || newName.includes("\\")) {
1086 this.renameError = "Name cannot include path separators.";
1087 return;
1088 }
1089 if (this.renameMode !== "create-folder" && !this.renameTarget?.path) {
1090 this.renameError = "No item selected for rename.";
1091 return;
1092 }
1093 if (this.renameValidateName) {
1094 const validation = this.renameValidateName(newName, this.renameTarget);
1095 if (validation !== true) {
1096 this.renameError = typeof validation === "string" ? validation : "Name is not valid.";
1097 return;
1098 }
1099 }
1100
1101 // UX: pre-validate duplicates so we can show a clean inline error (no toast spam)
1102 const duplicate = (this.browser.entries || []).some((entry) => {
1103 if (!entry?.name) return false;
1104 if (entry.name !== newName) return false;
1105 // When renaming, allow keeping the same entry name
1106 if (this.renameTarget?.path && entry.path === this.renameTarget.path) return false;
1107 return true;
1108 });
1109 if (duplicate) {
1110 this.renameError = `An item named "${newName}" already exists.`;
1111 return;
1112 }
1113
1114 this.isRenaming = true;
1115 this.renameError = null;
1116
1117 try {
1118 const previousPath = this.renameTarget?.path || "";
1119 const renamedPath =
1120 this.renameMode === "create-folder"
1121 ? this.buildChildPath(newName)
1122 : this.siblingPath(previousPath, newName);
1123 const payload =
1124 this.renameMode === "create-folder"
1125 ? {
1126 action: "create-folder",
1127 parentPath: this.browser.currentPath,
1128 currentPath: this.browser.currentPath,
1129 newName: newName,
1130 }
1131 : {
1132 action: "rename",
1133 path: this.renameTarget?.path,
1134 currentPath: this.browser.currentPath,
1135 newName: newName,
1136 };
1137
1138 let data = {};
1139 if (this.renamePerformAction) {
1140 data = await this.renamePerformAction({
1141 action: this.renameMode,
1142 previousPath,
1143 path: renamedPath,
1144 name: newName,
1145 target: this.renameTarget,
1146 payload,
1147 }) || {};
1148 if (data.error || data.ok === false) {
1149 throw new Error(data.error || "Rename failed");
1150 }
1151 } else {
1152 const resp = await fetchApi("/rename_work_dir_file", {
1153 method: "POST",
1154 headers: { "Content-Type": "application/json" },
1155 body: JSON.stringify(payload),
1156 });
1157
1158 data = await resp.json().catch(() => ({}));
1159 if (!resp.ok || data.error) {
1160 throw new Error(data.error || "Rename failed");
1161 }
1162 }
1163
1164 if (!this.renamePerformAction || data.refreshFiles !== false) {
1165 await this.fetchFiles(this.browser.currentPath);
1166 }
1167 if (this.renameAfterConfirm) {
1168 await this.renameAfterConfirm({
1169 action: this.renameMode,
1170 previousPath,
1171 path: renamedPath,
1172 name: newName,
1173 target: this.renameTarget,
1174 response: data,
1175 });
1176 }
1177 this.closeRenameModal();
1178 } catch (error) {
1179 const message = error?.message || "Rename failed";
1180 this.renameError = message;
1181 const title =
1182 this.renameMode === "create-folder" ? "Folder Error" : "Rename Error";
1183 window.toastFrontendError(message, title);
1184 } finally {
1185 this.isRenaming = false;
1186 }
1187 },
1188
1189 // --- File Editor (Delegated to FileEditorStore) --------------------------
1190 async openFileEditor(file) {
1191 await fileEditorStore.openFile(file, async () => {
1192 // Callback on successful save to refresh file list
1193 await this.fetchFiles(this.browser.currentPath);
1194 });
1195 },
1196
1197 async openNewFile() {
1198 const existingNames = (this.browser.entries || [])
1199 .map((e) => e?.name)
1200 .filter(Boolean);
1201 await fileEditorStore.openNewFile(this.browser.currentPath, existingNames, async () => {
1202 // Callback on successful save to refresh file list
1203 await this.fetchFiles(this.browser.currentPath);
1204 });
1205 },
1206
1207 // --- File actions --------------------------------------------------------
1208 async extractArchive(file = {}) {
1209 if (!file?.path || !this.isArchive(file.name) || this.isBulkBusy) return;
1210 this.isBulkBusy = true;
1211 this.closeDropdown();
1212 try {
1213 const resp = await fetchApi("/extract_work_dir_archive", {
1214 method: "POST",
1215 headers: { "Content-Type": "application/json" },
1216 body: JSON.stringify({ path: file.path, currentPath: this.browser.currentPath }),
1217 });
1218 const data = await resp.json().catch(() => ({}));
1219 if (!resp.ok || data.error) throw new Error(data.error || "Archive extraction failed");
1220 this.browser.entries = this.decorateEntries(data.data?.entries || []);
1221 this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
1222 this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
1223 window.toastFrontendSuccess(`Extracted to ${data.extracted_path || "a new folder"}`, "Archive Extracted");
1224 } catch (error) {
1225 window.toastFrontendError(error?.message || "Archive extraction failed", "Archive Extract Error");
1226 } finally {
1227 this.isBulkBusy = false;
1228 }
1229 },
1230
1231 async deleteFile(file) {
1232 try {
1233 const resp = await fetchApi("/delete_work_dir_file", {
1234 method: "POST",
1235 headers: { "Content-Type": "application/json" },
1236 body: JSON.stringify({
1237 path: file.path,
1238 currentPath: this.browser.currentPath,
1239 }),
1240 });
1241 const data = await resp.json().catch(() => ({}));
1242 if (resp.ok && !data.error) {
1243 this.browser.entries = this.browser.entries.filter(
1244 (e) => e.path !== file.path
1245 );
1246 window.toastFrontendSuccess("File deleted successfully", "File Deleted");
1247 } else {
1248 window.toastFrontendError(data.error || "Error deleting file", "Delete Error");
1249 }
1250 } catch (e) {
1251 window.toastFrontendError(
1252 "Error deleting file: " + e.message,
1253 "File Delete Error"
1254 );
1255 }
1256 },
1257
1258 copySelectedPaths() {
1259 const selectedFiles = this.selectedFiles;
1260 if (!selectedFiles.length) return;
1261
1262 const paths = selectedFiles.map((file) => file.path).join("\n");
1263 this.copyToClipboard(paths, () => {
1264 window.toastFrontendSuccess(
1265 `Copied ${selectedFiles.length} ${selectedFiles.length === 1 ? "path" : "paths"}`,
1266 "File Browser"
1267 );
1268 });
1269 },
1270
1271 copyToClipboard(text, onSuccess) {
1272 if (navigator.clipboard && window.isSecureContext) {
1273 navigator.clipboard
1274 .writeText(text)
1275 .then(() => onSuccess?.())
1276 .catch(() => this.fallbackCopyToClipboard(text, onSuccess));
1277 } else {
1278 this.fallbackCopyToClipboard(text, onSuccess);
1279 }
1280 },
1281
1282 fallbackCopyToClipboard(text, onSuccess) {
1283 const textArea = document.createElement("textarea");
1284 textArea.value = text;
1285 textArea.style.position = "fixed";
1286 textArea.style.left = "-999999px";
1287 textArea.style.top = "-999999px";
1288 document.body.appendChild(textArea);
1289 textArea.focus();
1290 textArea.select();
1291 try {
1292 document.execCommand("copy");
1293 onSuccess?.();
1294 } catch (error) {
1295 console.error("Clipboard copy failed:", error);
1296 window.toastFrontendError("Failed to copy selected paths", "File Browser");
1297 } finally {
1298 document.body.removeChild(textArea);
1299 }
1300 },
1301
1302 getDownloadFilename(response, fallback) {
1303 const disposition = response.headers.get("Content-Disposition") || "";
1304 const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
1305 if (utf8Match?.[1]) {
1306 try {
1307 return decodeURIComponent(utf8Match[1].replace(/^"|"$/g, ""));
1308 } catch {
1309 return utf8Match[1].replace(/^"|"$/g, "");
1310 }
1311 }
1312
1313 const asciiMatch = disposition.match(/filename="([^"]+)"/i);
1314 return asciiMatch?.[1] || fallback;
1315 },
1316
1317 createDownloadToastGroup(prefix) {
1318 return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1319 },
1320
1321 showDownloadPreparingToast(group) {
1322 window.toastFrontendInfo?.("Preparing download...", "Download", 0, group, undefined, true);
1323 },
1324
1325 showDownloadStartedToast(group) {
1326 window.toastFrontendInfo?.("Downloading...", "Download", 3, group, undefined, true);
1327 },
1328
1329 showDownloadErrorToast(group, message) {
1330 window.toastFrontendError?.(message || "Download failed", "Download Error", 8, group, undefined, true);
1331 },
1332
1333 async bulkDownloadFiles() {
1334 const selectedFiles = this.selectedFiles;
1335 if (!selectedFiles.length || this.isBulkBusy) return;
1336
1337 this.isBulkBusy = true;
1338 this.closeDropdown();
1339 const downloadToastGroup = this.createDownloadToastGroup("file-browser-bulk-download");
1340
1341 try {
1342 this.showDownloadPreparingToast(downloadToastGroup);
1343 const resp = await fetchApi("/download_work_dir_files", {
1344 method: "POST",
1345 headers: { "Content-Type": "application/json" },
1346 body: JSON.stringify({
1347 paths: selectedFiles.map((file) => file.path),
1348 currentPath: this.browser.currentPath,
1349 }),
1350 });
1351
1352 if (!resp.ok) {
1353 const message = await resp.text();
1354 throw new Error(message || "Download failed");
1355 }
1356
1357 const blob = await resp.blob();
1358 const url = URL.createObjectURL(blob);
1359 const fallback = `agent-zero-files-${selectedFiles.length}.zip`;
1360 const link = document.createElement("a");
1361 link.href = url;
1362 link.download = this.getDownloadFilename(resp, fallback);
1363 document.body.appendChild(link);
1364 link.click();
1365 document.body.removeChild(link);
1366 setTimeout(() => URL.revokeObjectURL(url), 0);
1367
1368 this.showDownloadStartedToast(downloadToastGroup);
1369 } catch (error) {
1370 this.showDownloadErrorToast(
1371 downloadToastGroup,
1372 error?.message || "Failed to download selected files"
1373 );
1374 } finally {
1375 this.isBulkBusy = false;
1376 }
1377 },
1378
1379 async bulkDeleteFiles() {
1380 const selectedFiles = this.selectedFiles;
1381 if (!selectedFiles.length || this.isBulkBusy) return;
1382
1383 this.isBulkBusy = true;
1384 this.closeDropdown();
1385
1386 try {
1387 const resp = await fetchApi("/delete_work_dir_files", {
1388 method: "POST",
1389 headers: { "Content-Type": "application/json" },
1390 body: JSON.stringify({
1391 paths: selectedFiles.map((file) => file.path),
1392 currentPath: this.browser.currentPath,
1393 }),
1394 });
1395 const data = await resp.json().catch(() => ({}));
1396
1397 if (resp.ok && !data.error) {
1398 this.browser.entries = this.decorateEntries(data.data?.entries || []);
1399 this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
1400 this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
1401 const deletedCount = data.deleted?.length || selectedFiles.length;
1402 window.toastFrontendSuccess(
1403 `Deleted ${deletedCount} ${deletedCount === 1 ? "item" : "items"}`,
1404 "File Browser"
1405 );
1406
1407 if (data.failed?.length) {
1408 window.toastFrontendError(
1409 `${data.failed.length} selected ${data.failed.length === 1 ? "item" : "items"} could not be deleted`,
1410 "File Browser"
1411 );
1412 }
1413 } else {
1414 window.toastFrontendError(
1415 data.error || "Error deleting selected files",
1416 "File Browser"
1417 );
1418 }
1419 } catch (error) {
1420 window.toastFrontendError(
1421 "Error deleting selected files: " + error.message,
1422 "File Browser"
1423 );
1424 } finally {
1425 this.isBulkBusy = false;
1426 }
1427 },
1428
1429 async handleFileUpload(event) {
1430 return store._handleFileUpload(event); // bind to model to ensure correct context
1431 },
1432
1433 async openInSurface(file = {}) {
1434 const target = this.fileSurfaceTarget(file);
1435 const path = this.normalizePath(String(file?.path || ""));
1436 if (!target || !path) return;
1437
1438 this.closeDropdown();
1439
1440 try {
1441 if (target === "browser") {
1442 const url = this.fileUrl(file);
1443 const { store: browserStore } = await import("/plugins/_browser/webui/browser-store.js");
1444 await openLatestSurface("browser", { url, source: "file-browser" });
1445
1446 let opened = false;
1447 for (let attempt = 0; attempt < 40 && !opened; attempt += 1) {
1448 opened = await browserStore.openUrlIntent(url, { source: "file-browser" });
1449 if (!opened) await delay(75);
1450 }
1451 if (!opened) {
1452 throw new Error("Browser surface is unavailable.");
1453 }
1454 } else {
1455 await openLatestSurface(target, { path, source: "file-browser" });
1456 if (target === "editor") {
1457 const { store: editorStore } = await import("/plugins/_editor/webui/editor-store.js");
1458 if (!this.storeHasPath(editorStore, path)) {
1459 const session = await editorStore.openPath(path, { source: "file-browser" });
1460 if (!session || session.ok === false) {
1461 throw new Error(editorStore.error || "Text document could not be opened.");
1462 }
1463 }
1464 }
1465 if (target === "desktop") {
1466 const { store: desktopStore } = await import("/plugins/_desktop/webui/desktop-store.js");
1467 if (!this.storeHasPath(desktopStore, path)) {
1468 const session = await desktopStore.openPath(path);
1469 if (!session || session.ok === false) {
1470 throw new Error(desktopStore.error || "Document could not be opened.");
1471 }
1472 }
1473 }
1474 }
1475
1476 this.disposeScopedTooltips();
1477 await window.closeModal?.(FILE_BROWSER_MODAL_PATH);
1478 } catch (error) {
1479 window.toastFrontendError?.(
1480 error?.message || "Could not open file",
1481 "File Browser"
1482 );
1483 }
1484 },
1485
1486 async _handleFileUpload(event) {
1487 try {
1488 const files = event.target.files;
1489 if (!files.length) return;
1490 const formData = new FormData();
1491 formData.append("path", this.browser.currentPath);
1492 for (let f of files) {
1493 const ext = f.name.split(".").pop().toLowerCase();
1494 if (
1495 !["zip", "tar", "gz", "rar", "7z"].includes(ext) &&
1496 f.size > 100 * 1024 * 1024
1497 ) {
1498 alert(`File ${f.name} exceeds 100MB limit.`);
1499 continue;
1500 }
1501 formData.append("files[]", f);
1502 }
1503 const resp = await fetchApi("/upload_work_dir_files", {
1504 method: "POST",
1505 body: formData,
1506 });
1507 const data = await resp.json().catch(() => ({}));
1508 if (resp.ok && !data.error) {
1509 this.browser.entries = this.decorateEntries(data.data.entries || []);
1510 this.browser.currentPath = data.data.current_path;
1511 this.browser.parentPath = data.data.parent_path;
1512 if (data.failed && data.failed.length) {
1513 const msg = data.failed
1514 .map((f) => `${f.name}: ${f.error}`)
1515 .join("\n");
1516 alert(`Some files failed to upload:\n${msg}`);
1517 }
1518 } else {
1519 alert(data.error || "Error uploading files");
1520 }
1521 } catch (e) {
1522 window.toastFrontendError(
1523 "Error uploading files: " + e.message,
1524 "File Upload Error"
1525 );
1526 } finally {
1527 event.target.value = ""; // reset input so same file can be reselected
1528 }
1529 },
1530
1531 async downloadDirectory(file) {
1532 const downloadToastGroup = this.createDownloadToastGroup("file-browser-directory-download");
1533
1534 try {
1535 this.showDownloadPreparingToast(downloadToastGroup);
1536 const resp = await fetchApi(`/download_work_dir_file?path=${encodeURIComponent(file.path)}`, {
1537 method: "GET",
1538 });
1539
1540 if (!resp.ok) {
1541 const message = await resp.text();
1542 throw new Error(message || "Download failed");
1543 }
1544
1545 const blob = await resp.blob();
1546 const url = URL.createObjectURL(blob);
1547 const fallback = `${file.name}.zip`;
1548 const link = document.createElement("a");
1549 link.href = url;
1550 link.download = this.getDownloadFilename(resp, fallback);
1551 document.body.appendChild(link);
1552 link.click();
1553 document.body.removeChild(link);
1554 setTimeout(() => URL.revokeObjectURL(url), 0);
1555 this.showDownloadStartedToast(downloadToastGroup);
1556 } catch (error) {
1557 this.showDownloadErrorToast(
1558 downloadToastGroup,
1559 error?.message || "Failed to download directory"
1560 );
1561 }
1562 },
1563
1564 downloadFile(file) {
1565 if (file.is_dir) {
1566 return this.downloadDirectory(file);
1567 }
1568
1569 const link = document.createElement("a");
1570 link.href = `/api/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
1571 link.download = file.name;
1572 document.body.appendChild(link);
1573 link.click();
1574 document.body.removeChild(link);
1575 },
1576 };
1577
1578 export const store = createStore("fileBrowser", model);
1579
1580 window.openFileLink = async function (path) {
1581 try {
1582 const resp = await window.sendJsonData("/file_info", { path });
1583 if (!resp.exists) {
1584 window.toastFrontendError("File does not exist.", "File Error");
1585 return;
1586 }
1587 if (resp.is_dir) {
1588 // Set initial path and open via store
1589 await store.open(resp.abs_path);
1590 } else {
1591 store.downloadFile({ path: resp.abs_path, name: resp.file_name });
1592 }
1593 } catch (e) {
1594 window.toastFrontendError(
1595 "Error opening file: " + e.message,
1596 "File Open Error"
1597 );
1598 }
1599 };