Add file browser search and bulk actions

Adds a branded file-browser toolbar with current-folder search, visible selection, and selection status. Introduces bulk copy paths, ZIP download, and delete actions backed by dedicated API handlers so selected files are processed as one browser operation.

Alessandro committed May 2, 2026 at 19:44 UTC f17e13b859e21d8486b9b7845512182e93b9bc6b
4 files changed +760 -22
api/delete_work_dir_files.py new
+83
@@ -0,0 +1,83 @@
1 +from helpers.api import ApiHandler, Input, Output, Request
2 +from helpers.file_browser import FileBrowser
3 +from helpers import runtime, extension
4 +from api import get_work_dir_files
5 +from api.download_work_dir_files import normalize_paths
6 +
7 +
8 +class DeleteWorkDirFiles(ApiHandler):
9 + async def process(self, input: Input, request: Request) -> Output:
10 + try:
11 + paths = normalize_paths(input.get("paths", []))
12 + except ValueError as exc:
13 + return {"error": str(exc)}
14 +
15 + current_path = input.get("currentPath", "")
16 +
17 + if not paths:
18 + return {"error": "No file paths provided"}
19 +
20 + result = await runtime.call_development_function(delete_files, paths)
21 + deleted = result["deleted"]
22 + failed = result["failed"]
23 +
24 + if deleted:
25 + await extension.call_extensions_async(
26 + "workdir_file_mutation_after",
27 + agent=None,
28 + data={
29 + "action": "bulk_delete",
30 + "path": deleted[0],
31 + "paths": deleted,
32 + "current_path": current_path,
33 + },
34 + )
35 +
36 + files_result = await runtime.call_development_function(
37 + get_work_dir_files.get_files, current_path
38 + )
39 +
40 + if not deleted:
41 + return {
42 + "error": "Selected items could not be deleted",
43 + "data": files_result,
44 + "deleted": deleted,
45 + "failed": failed,
46 + }
47 +
48 + return {
49 + "data": files_result,
50 + "deleted": deleted,
51 + "failed": failed,
52 + }
53 +
54 +
55 +async def delete_files(paths: list[str]) -> dict:
56 + browser = FileBrowser()
57 + deleted: list[str] = []
58 + failed: list[str] = []
59 +
60 + for path in collapse_nested_paths(paths):
61 + if path == "/":
62 + failed.append(path)
63 + continue
64 +
65 + if browser.delete_file(path):
66 + deleted.append(path)
67 + else:
68 + failed.append(path)
69 +
70 + return {"deleted": deleted, "failed": failed}
71 +
72 +
73 +def collapse_nested_paths(paths: list[str]) -> list[str]:
74 + collapsed: list[str] = []
75 + for path in sorted(normalize_paths(paths), key=lambda item: item.count("/")):
76 + clean_path = "/" + path.strip("/")
77 + if any(
78 + clean_path == parent or clean_path.startswith(parent.rstrip("/") + "/")
79 + for parent in collapsed
80 + ):
81 + continue
82 + collapsed.append(clean_path)
83 + return collapsed
api/download_work_dir_files.py new
+179
@@ -0,0 +1,179 @@
1 +import base64
2 +from datetime import datetime
3 +from io import BytesIO
4 +import os
5 +from pathlib import Path
6 +import tempfile
7 +import zipfile
8 +
9 +from flask import Response
10 +
11 +from helpers.api import ApiHandler, Input, Output, Request
12 +from helpers import files, runtime
13 +from api.download_work_dir_file import fetch_file, stream_file_download
14 +
15 +
16 +class DownloadFiles(ApiHandler):
17 + async def process(self, input: Input, request: Request) -> Output:
18 + try:
19 + paths = normalize_paths(input.get("paths", []))
20 + except ValueError as exc:
21 + return Response(str(exc), status=400)
22 +
23 + current_path = input.get("currentPath", "")
24 +
25 + if not paths:
26 + return Response("No file paths provided", status=400)
27 +
28 + try:
29 + zip_file = await runtime.call_development_function(
30 + create_selected_zip, paths, current_path
31 + )
32 + except ValueError as exc:
33 + return Response(str(exc), status=400)
34 + except FileNotFoundError as exc:
35 + return Response(str(exc), status=404)
36 +
37 + download_name = selected_archive_name(len(paths))
38 + if runtime.is_development():
39 + b64 = await runtime.call_development_function(fetch_file, zip_file)
40 + file_data = BytesIO(base64.b64decode(b64))
41 + return stream_file_download(file_data, download_name=download_name)
42 +
43 + return stream_file_download(zip_file, download_name=download_name)
44 +
45 +
46 +def normalize_paths(paths) -> list[str]:
47 + if not isinstance(paths, list):
48 + raise ValueError("Paths must be a list")
49 +
50 + normalized: list[str] = []
51 + seen: set[str] = set()
52 + for raw_path in paths:
53 + if not isinstance(raw_path, str):
54 + continue
55 + path = raw_path.strip()
56 + if not path:
57 + continue
58 + if not path.startswith("/"):
59 + path = f"/{path}"
60 + if path not in seen:
61 + normalized.append(path)
62 + seen.add(path)
63 +
64 + return normalized
65 +
66 +
67 +def selected_archive_name(count: int) -> str:
68 + stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
69 + return f"agent-zero-selected-{count}-{stamp}.zip"
70 +
71 +
72 +def create_selected_zip(paths: list[str], current_path: str = "") -> str:
73 + base_dir = Path(files.get_base_dir()).resolve()
74 + current_dir = resolve_download_path(current_path, base_dir) if current_path else None
75 + if current_dir and current_dir.is_file():
76 + current_dir = current_dir.parent
77 +
78 + selected_paths = []
79 + for path in normalize_paths(paths):
80 + resolved = resolve_download_path(path, base_dir)
81 + if resolved.exists():
82 + selected_paths.append(resolved)
83 +
84 + selected_paths = collapse_nested_paths(selected_paths)
85 + if not selected_paths:
86 + raise FileNotFoundError("No selected files were found")
87 +
88 + zip_file_path = tempfile.NamedTemporaryFile(suffix=".zip", delete=False).name
89 + used_names: set[str] = set()
90 +
91 + with zipfile.ZipFile(
92 + zip_file_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
93 + ) as zip_file:
94 + for source_path in selected_paths:
95 + arc_root = unique_archive_name(
96 + archive_root_name(source_path, current_dir, base_dir), used_names
97 + )
98 + write_zip_entry(zip_file, source_path, arc_root)
99 +
100 + return zip_file_path
101 +
102 +
103 +def resolve_download_path(path: str, base_dir: Path) -> Path:
104 + if not path:
105 + raise ValueError("Invalid file path")
106 +
107 + candidate = Path(path)
108 + resolved = candidate.resolve() if candidate.is_absolute() else (base_dir / candidate).resolve()
109 +
110 + try:
111 + resolved.relative_to(base_dir)
112 + except ValueError as exc:
113 + raise ValueError("Invalid file path") from exc
114 +
115 + return resolved
116 +
117 +
118 +def collapse_nested_paths(paths: list[Path]) -> list[Path]:
119 + collapsed: list[Path] = []
120 + for path in sorted(paths, key=lambda item: len(item.parts)):
121 + if any(path == parent or parent in path.parents for parent in collapsed):
122 + continue
123 + collapsed.append(path)
124 + return collapsed
125 +
126 +
127 +def archive_root_name(source_path: Path, current_dir: Path | None, base_dir: Path) -> str:
128 + if current_dir:
129 + try:
130 + return source_path.relative_to(current_dir).as_posix().strip("/")
131 + except ValueError:
132 + pass
133 +
134 + try:
135 + return source_path.relative_to(base_dir).as_posix().strip("/")
136 + except ValueError:
137 + return source_path.name
138 +
139 +
140 +def unique_archive_name(name: str, used_names: set[str]) -> str:
141 + clean_name = name or "selection"
142 + if clean_name not in used_names:
143 + used_names.add(clean_name)
144 + return clean_name
145 +
146 + stem, suffix = os.path.splitext(clean_name)
147 + index = 2
148 + while True:
149 + candidate = f"{stem}-{index}{suffix}"
150 + if candidate not in used_names:
151 + used_names.add(candidate)
152 + return candidate
153 + index += 1
154 +
155 +
156 +def write_zip_entry(zip_file: zipfile.ZipFile, source_path: Path, arc_root: str) -> None:
157 + if source_path.is_dir():
158 + wrote_any = False
159 + for root, dirs, file_names in os.walk(source_path):
160 + dirs.sort()
161 + file_names.sort()
162 + root_path = Path(root)
163 + rel_root = root_path.relative_to(source_path)
164 +
165 + if not dirs and not file_names:
166 + empty_dir = Path(arc_root) / rel_root
167 + zip_file.writestr(empty_dir.as_posix().rstrip("/") + "/", "")
168 +
169 + for file_name in file_names:
170 + file_path = root_path / file_name
171 + rel_path = file_path.relative_to(source_path)
172 + zip_file.write(file_path, (Path(arc_root) / rel_path).as_posix())
173 + wrote_any = True
174 +
175 + if not wrote_any:
176 + zip_file.writestr(Path(arc_root).as_posix().rstrip("/") + "/", "")
177 + return
178 +
179 + zip_file.write(source_path, arc_root)
webui/components/modals/file-browser/file-browser-store.js
+241 -2
@@ -26,6 +26,8 @@ const model = {
26 renameAfterConfirm: null,
27 renameValidateName: null,
28 openDropdownPath: null, // Track which dropdown is currently open
29 + searchQuery: "",
30 + isBulkBusy: false,
31
32 // --- Lifecycle -----------------------------------------------------------
33 init() {
@@ -38,6 +40,8 @@ const model = {
40 this.isLoading = true;
41 this.error = null;
42 this.history = [];
43 + this.searchQuery = "";
44 + this.isBulkBusy = false;
45
46 try {
47 // Open modal FIRST (immediate UI feedback)
@@ -75,6 +79,8 @@ const model = {
79 this.initialPath = "";
80 this.browser.entries = [];
81 this.openDropdownPath = null;
82 + this.searchQuery = "";
83 + this.isBulkBusy = false;
84 this.resetRenameState();
85 },
86
@@ -137,6 +143,76 @@ const model = {
143 return new Date(dateString).toLocaleDateString(undefined, options);
144 },
145
146 + decorateEntries(entries = [], selectedPaths = new Set()) {
147 + return entries.map((entry) => ({
148 + ...entry,
149 + selected: selectedPaths.has(entry.path),
150 + }));
151 + },
152 +
153 + get filteredEntries() {
154 + const query = this.searchQuery.trim().toLowerCase();
155 + if (!query) return this.browser.entries;
156 +
157 + return this.browser.entries.filter((file) => {
158 + const searchable = [
159 + file.name,
160 + file.path,
161 + file.type,
162 + file.symlink_target,
163 + file.is_dir ? "folder directory" : "file",
164 + ]
165 + .filter(Boolean)
166 + .join(" ")
167 + .toLowerCase();
168 + return searchable.includes(query);
169 + });
170 + },
171 +
172 + get visibleEntries() {
173 + return this.sortFiles(this.filteredEntries);
174 + },
175 +
176 + clearSearch() {
177 + this.searchQuery = "";
178 + },
179 +
180 + get selectedFiles() {
181 + return this.browser.entries.filter((file) => file.selected);
182 + },
183 +
184 + get selectedCount() {
185 + return this.selectedFiles.length;
186 + },
187 +
188 + get selectedCountLabel() {
189 + return `${this.selectedCount} ${this.selectedCount === 1 ? "item" : "items"} selected`;
190 + },
191 +
192 + get allVisibleSelected() {
193 + return (
194 + this.filteredEntries.length > 0 &&
195 + this.filteredEntries.every((file) => file.selected)
196 + );
197 + },
198 +
199 + get someVisibleSelected() {
200 + return this.filteredEntries.some((file) => file.selected);
201 + },
202 +
203 + toggleSelectAllVisible() {
204 + const shouldSelect = !this.allVisibleSelected;
205 + this.filteredEntries.forEach((file) => {
206 + file.selected = shouldSelect;
207 + });
208 + },
209 +
210 + clearSelection() {
211 + this.browser.entries.forEach((file) => {
212 + file.selected = false;
213 + });
214 + },
215 +
216 // --- Modal helpers -------------------------------------------------------
217 normalizePath(path) {
218 if (!path) return "";
@@ -223,6 +299,9 @@ const model = {
299 const isSamePath = this.browser.currentPath === path ||
300 (!path && !this.browser.currentPath);
301 const scrollPos = isSamePath ? this.saveScrollPosition() : null;
302 + const selectedPaths = isSamePath
303 + ? new Set(this.selectedFiles.map((file) => file.path))
304 + : new Set();
305
306 try {
307 const response = await fetchApi(
@@ -231,7 +310,11 @@ const model = {
310 const data = await response.json().catch(() => ({}));
311
312 if (response.ok && !data.error) {
234 - this.browser.entries = data.data.entries;
313 + if (!isSamePath) this.searchQuery = "";
314 + this.browser.entries = this.decorateEntries(
315 + data.data.entries || [],
316 + selectedPaths
317 + );
318 this.browser.currentPath = data.data.current_path;
319 this.browser.parentPath = data.data.parent_path;
320
@@ -448,6 +531,162 @@ const model = {
531 }
532 },
533
534 + copySelectedPaths() {
535 + const selectedFiles = this.selectedFiles;
536 + if (!selectedFiles.length) return;
537 +
538 + const paths = selectedFiles.map((file) => file.path).join("\n");
539 + this.copyToClipboard(paths, () => {
540 + window.toastFrontendSuccess(
541 + `Copied ${selectedFiles.length} ${selectedFiles.length === 1 ? "path" : "paths"}`,
542 + "File Browser"
543 + );
544 + });
545 + },
546 +
547 + copyToClipboard(text, onSuccess) {
548 + if (navigator.clipboard && window.isSecureContext) {
549 + navigator.clipboard
550 + .writeText(text)
551 + .then(() => onSuccess?.())
552 + .catch(() => this.fallbackCopyToClipboard(text, onSuccess));
553 + } else {
554 + this.fallbackCopyToClipboard(text, onSuccess);
555 + }
556 + },
557 +
558 + fallbackCopyToClipboard(text, onSuccess) {
559 + const textArea = document.createElement("textarea");
560 + textArea.value = text;
561 + textArea.style.position = "fixed";
562 + textArea.style.left = "-999999px";
563 + textArea.style.top = "-999999px";
564 + document.body.appendChild(textArea);
565 + textArea.focus();
566 + textArea.select();
567 + try {
568 + document.execCommand("copy");
569 + onSuccess?.();
570 + } catch (error) {
571 + console.error("Clipboard copy failed:", error);
572 + window.toastFrontendError("Failed to copy selected paths", "File Browser");
573 + } finally {
574 + document.body.removeChild(textArea);
575 + }
576 + },
577 +
578 + getDownloadFilename(response, fallback) {
579 + const disposition = response.headers.get("Content-Disposition") || "";
580 + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
581 + if (utf8Match?.[1]) {
582 + try {
583 + return decodeURIComponent(utf8Match[1].replace(/^"|"$/g, ""));
584 + } catch {
585 + return utf8Match[1].replace(/^"|"$/g, "");
586 + }
587 + }
588 +
589 + const asciiMatch = disposition.match(/filename="([^"]+)"/i);
590 + return asciiMatch?.[1] || fallback;
591 + },
592 +
593 + async bulkDownloadFiles() {
594 + const selectedFiles = this.selectedFiles;
595 + if (!selectedFiles.length || this.isBulkBusy) return;
596 +
597 + this.isBulkBusy = true;
598 + this.closeDropdown();
599 +
600 + try {
601 + const resp = await fetchApi("/download_work_dir_files", {
602 + method: "POST",
603 + headers: { "Content-Type": "application/json" },
604 + body: JSON.stringify({
605 + paths: selectedFiles.map((file) => file.path),
606 + currentPath: this.browser.currentPath,
607 + }),
608 + });
609 +
610 + if (!resp.ok) {
611 + const message = await resp.text();
612 + throw new Error(message || "Download failed");
613 + }
614 +
615 + const blob = await resp.blob();
616 + const url = URL.createObjectURL(blob);
617 + const fallback = `agent-zero-files-${selectedFiles.length}.zip`;
618 + const link = document.createElement("a");
619 + link.href = url;
620 + link.download = this.getDownloadFilename(resp, fallback);
621 + document.body.appendChild(link);
622 + link.click();
623 + document.body.removeChild(link);
624 + setTimeout(() => URL.revokeObjectURL(url), 0);
625 +
626 + window.toastFrontendSuccess(
627 + `Prepared ${selectedFiles.length} ${selectedFiles.length === 1 ? "item" : "items"} as ZIP`,
628 + "File Browser"
629 + );
630 + } catch (error) {
631 + window.toastFrontendError(
632 + error?.message || "Failed to download selected files",
633 + "File Browser"
634 + );
635 + } finally {
636 + this.isBulkBusy = false;
637 + }
638 + },
639 +
640 + async bulkDeleteFiles() {
641 + const selectedFiles = this.selectedFiles;
642 + if (!selectedFiles.length || this.isBulkBusy) return;
643 +
644 + this.isBulkBusy = true;
645 + this.closeDropdown();
646 +
647 + try {
648 + const resp = await fetchApi("/delete_work_dir_files", {
649 + method: "POST",
650 + headers: { "Content-Type": "application/json" },
651 + body: JSON.stringify({
652 + paths: selectedFiles.map((file) => file.path),
653 + currentPath: this.browser.currentPath,
654 + }),
655 + });
656 + const data = await resp.json().catch(() => ({}));
657 +
658 + if (resp.ok && !data.error) {
659 + this.browser.entries = this.decorateEntries(data.data?.entries || []);
660 + this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
661 + this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
662 + const deletedCount = data.deleted?.length || selectedFiles.length;
663 + window.toastFrontendSuccess(
664 + `Deleted ${deletedCount} ${deletedCount === 1 ? "item" : "items"}`,
665 + "File Browser"
666 + );
667 +
668 + if (data.failed?.length) {
669 + window.toastFrontendError(
670 + `${data.failed.length} selected ${data.failed.length === 1 ? "item" : "items"} could not be deleted`,
671 + "File Browser"
672 + );
673 + }
674 + } else {
675 + window.toastFrontendError(
676 + data.error || "Error deleting selected files",
677 + "File Browser"
678 + );
679 + }
680 + } catch (error) {
681 + window.toastFrontendError(
682 + "Error deleting selected files: " + error.message,
683 + "File Browser"
684 + );
685 + } finally {
686 + this.isBulkBusy = false;
687 + }
688 + },
689 +
690 async handleFileUpload(event) {
691 return store._handleFileUpload(event); // bind to model to ensure correct context
692 },
@@ -475,7 +714,7 @@ const model = {
714 });
715 const data = await resp.json().catch(() => ({}));
716 if (resp.ok && !data.error) {
478 - this.browser.entries = data.data.entries;
717 + this.browser.entries = this.decorateEntries(data.data.entries || []);
718 this.browser.currentPath = data.data.current_path;
719 this.browser.parentPath = data.data.parent_path;
720 if (data.failed && data.failed.length) {
webui/components/modals/file-browser/file-browser.html
+257 -20
@@ -28,29 +28,142 @@
28 </button>
29 <div id="current-path"><span id="path-text" x-text="$store.fileBrowser.browser.currentPath"></span></div>
30 </div>
31 - <div class="new-item-buttons">
32 - <button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFile()">
33 - <span class="material-symbols-outlined">note_add</span>
34 - New File
35 - </button>
36 - <button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFolderModal()">
37 - <span class="material-symbols-outlined">create_new_folder</span>
38 - New Folder
39 - </button>
31 + <div class="file-browser-toolbar">
32 + <div class="file-search-shell">
33 + <span class="material-symbols-outlined file-search-icon" aria-hidden="true">search</span>
34 + <input
35 + type="search"
36 + class="file-search-input"
37 + x-model="$store.fileBrowser.searchQuery"
38 + @keydown.escape="$store.fileBrowser.clearSearch()"
39 + placeholder="Search files..."
40 + aria-label="Search files"
41 + />
42 + <button
43 + type="button"
44 + class="btn-icon-action file-search-clear"
45 + x-show="$store.fileBrowser.searchQuery"
46 + @click="$store.fileBrowser.clearSearch()"
47 + aria-label="Clear search"
48 + title="Clear search"
49 + >
50 + <span class="material-symbols-outlined">close</span>
51 + </button>
52 + </div>
53 +
54 + <div class="new-item-buttons">
55 + <button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFile()">
56 + <span class="material-symbols-outlined">note_add</span>
57 + New File
58 + </button>
59 + <button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFolderModal()">
60 + <span class="material-symbols-outlined">create_new_folder</span>
61 + New Folder
62 + </button>
63 + </div>
64 + </div>
65 +
66 + <div class="data-status-bar data-status-bar-standalone file-status-bar">
67 + <div class="status-info">
68 + <span class="status-item">
69 + <span class="material-symbols-outlined">folder</span>
70 + Total: <strong x-text="$store.fileBrowser.browser.entries.length"></strong>
71 + </span>
72 + <span class="status-separator">•</span>
73 + <span class="status-item">
74 + Filtered: <strong x-text="$store.fileBrowser.filteredEntries.length"></strong>
75 + </span>
76 + <template x-if="$store.fileBrowser.selectedCount > 0">
77 + <span class="status-separator">•</span>
78 + </template>
79 + <template x-if="$store.fileBrowser.selectedCount > 0">
80 + <span class="status-item">
81 + Selected: <strong x-text="$store.fileBrowser.selectedCount"></strong>
82 + </span>
83 + </template>
84 + </div>
85 + </div>
86 +
87 + <div x-show="$store.fileBrowser.selectedCount > 0" class="mass-action-toolbar file-mass-toolbar">
88 + <div class="selection-info" x-text="$store.fileBrowser.selectedCountLabel"></div>
89 +
90 + <div class="mass-actions">
91 + <button
92 + type="button"
93 + class="btn btn-mass copy"
94 + @click="$store.fileBrowser.copySelectedPaths()"
95 + :disabled="$store.fileBrowser.isBulkBusy"
96 + title="Copy Selected Paths"
97 + >
98 + <span class="material-symbols-outlined">content_copy</span>
99 + Copy Paths
100 + </button>
101 +
102 + <button
103 + type="button"
104 + class="btn btn-mass export"
105 + @click="$store.fileBrowser.bulkDownloadFiles()"
106 + :disabled="$store.fileBrowser.isBulkBusy"
107 + title="Download Selected Items"
108 + >
109 + <span class="material-symbols-outlined">folder_zip</span>
110 + Download ZIP
111 + </button>
112 +
113 + <button
114 + type="button"
115 + class="btn btn-mass delete"
116 + @click="$confirmClick($event, () => $store.fileBrowser.bulkDeleteFiles())"
117 + :disabled="$store.fileBrowser.isBulkBusy"
118 + title="Delete Selected Items"
119 + >
120 + <span class="material-symbols-outlined">delete</span>
121 + Delete
122 + </button>
123 +
124 + <button
125 + type="button"
126 + class="btn btn-mass clear"
127 + @click="$store.fileBrowser.clearSelection()"
128 + :disabled="$store.fileBrowser.isBulkBusy"
129 + title="Clear Selection"
130 + >
131 + <span class="material-symbols-outlined">close</span>
132 + Clear
133 + </button>
134 + </div>
135 </div>
136
137 <!-- Files list -->
138 <div class="files-list">
139 <div class="file-header">
140 + <div class="file-cell-select">
141 + <input
142 + type="checkbox"
143 + :checked="$store.fileBrowser.allVisibleSelected"
144 + :indeterminate="$store.fileBrowser.someVisibleSelected && !$store.fileBrowser.allVisibleSelected"
145 + @change="$store.fileBrowser.toggleSelectAllVisible()"
146 + title="Select visible items"
147 + aria-label="Select visible items"
148 + />
149 + </div>
150 <div class="file-cell" @click="$store.fileBrowser.toggleSort('name')">Name <span x-show="$store.fileBrowser.browser.sortBy === 'name'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
151 <div class="file-cell-size" @click="$store.fileBrowser.toggleSort('size')">Size <span x-show="$store.fileBrowser.browser.sortBy === 'size'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
152 <div class="file-cell-date" @click="$store.fileBrowser.toggleSort('date')">Modified <span x-show="$store.fileBrowser.browser.sortBy === 'date'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
153 + <div class="file-cell-actions"></div>
154 </div>
155
156 <!-- File list entries -->
51 - <template x-if="$store.fileBrowser.browser.entries.length">
52 - <template x-for="file in $store.fileBrowser.sortFiles($store.fileBrowser.browser.entries)" :key="file.path">
53 - <div class="file-item" :data-is-dir="file.is_dir">
157 + <template x-if="$store.fileBrowser.visibleEntries.length">
158 + <template x-for="file in $store.fileBrowser.visibleEntries" :key="file.path">
159 + <div class="file-item" :data-is-dir="file.is_dir" :class="{ 'selected': file.selected }">
160 + <label class="file-select-cell" @click.stop>
161 + <input
162 + type="checkbox"
163 + x-model="file.selected"
164 + :aria-label="`Select ${file.name}`"
165 + />
166 + </label>
167 <div class="file-name" @click="file.is_dir && $store.fileBrowser.navigateToFolder(file.path)">
168 <img :src="'/public/' + (file.type === 'unknown' ? 'file' : ($store.fileBrowser.isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type" />
169 <span x-text="file.name"></span>
@@ -116,7 +229,7 @@
229 <button class="btn-icon-action" x-show="!file.is_dir" @click.stop="$store.fileBrowser.downloadFile(file)" title="Download file">
230 <span class="material-symbols-outlined">download</span>
231 </button>
119 - <button class="btn-icon-action" @click.stop="$confirmClick($event, () => $store.fileBrowser.deleteFile(file))" title="Delete item">
232 + <button class="btn-icon-action danger" @click.stop="$confirmClick($event, () => $store.fileBrowser.deleteFile(file))" title="Delete item">
233 <span class="material-symbols-outlined">delete</span>
234 </button>
235 </div>
@@ -128,6 +241,15 @@
241 <template x-if="!$store.fileBrowser.browser.entries.length">
242 <div class="no-files">No files found</div>
243 </template>
244 + <template x-if="$store.fileBrowser.browser.entries.length && !$store.fileBrowser.visibleEntries.length">
245 + <div class="no-files">
246 + <div>No matching files found</div>
247 + <button class="btn btn-cancel btn-clear-search" @click="$store.fileBrowser.clearSearch()">
248 + <span class="material-symbols-outlined">close</span>
249 + Clear
250 + </button>
251 + </div>
252 + </template>
253 </div>
254 </div>
255 </div>
@@ -193,6 +315,7 @@
315 display: flex;
316 flex-direction: column;
317 padding: var(--spacing-sm) var(--spacing-sm);
318 + gap: var(--spacing-sm);
319 }
320
321 /* File Browser Styles */
@@ -208,13 +331,19 @@
331 border-radius: 4px;
332 overflow: hidden;
333 display: grid;
211 - grid-template-columns: 1.5fr 0.71fr 1.05fr 0.6fr;
334 + grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 7.5rem;
335 background: var(--secondary-bg);
336 padding: 8px 0;
337 font-weight: bold;
338 border-bottom: 1px solid var(--border-color);
339 color: var(--color-primary);
340 }
341 + .file-cell-select,
342 + .file-cell-actions {
343 + display: flex;
344 + align-items: center;
345 + justify-content: center;
346 + }
347 .file-cell,
348 .file-cell-size,
349 .file-cell-date {
@@ -226,7 +355,7 @@
355 /* File Item Styles */
356 .file-item {
357 display: grid;
229 - grid-template-columns: 1.5fr 0.7fr 1fr 0.6fr;
358 + grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 7.5rem;
359 align-items: center;
360 padding: 8px 0;
361 font-size: 0.875rem;
@@ -240,6 +369,25 @@
369 .file-item:hover {
370 background-color: var(--color-secondary);
371 }
372 + .file-item.selected {
373 + background: color-mix(in srgb, var(--color-primary) 12%, transparent);
374 + }
375 +
376 + .file-select-cell {
377 + display: flex;
378 + align-items: center;
379 + justify-content: center;
380 + cursor: pointer;
381 + min-height: 1.75rem;
382 + }
383 +
384 + .file-cell-select input,
385 + .file-select-cell input {
386 + width: 1rem;
387 + height: 1rem;
388 + accent-color: var(--color-primary);
389 + cursor: pointer;
390 + }
391
392 /* File Icon and Name */
393 .file-icon {
@@ -271,6 +419,12 @@
419 text-align: center;
420 color: var(--text-secondary);
421 }
422 + .no-files .btn-clear-search {
423 + display: inline-flex;
424 + align-items: center;
425 + gap: 0.25rem;
426 + margin-top: 0.75rem;
427 + }
428 /* Light Mode Adjustments */
429 .light-mode .file-item:hover {
430 background-color: var(--color-secondary-light);
@@ -284,20 +438,86 @@
438 gap: 24px;
439 background-color: var(--color-message-bg);
440 padding: 0.5rem var(--spacing-sm);
287 - margin: 0 0 var(--spacing-sm) 0;
441 + margin: 0;
442 border: 1px solid var(--color-border);
443 border-radius: 8px;
444 }
445
446 + .file-browser-toolbar {
447 + display: flex;
448 + align-items: center;
449 + justify-content: space-between;
450 + gap: var(--spacing-sm);
451 + }
452 +
453 + .file-search-shell {
454 + position: relative;
455 + display: flex;
456 + align-items: center;
457 + min-width: 16rem;
458 + flex: 1;
459 + }
460 +
461 + .file-search-icon {
462 + position: absolute;
463 + left: 0.65rem;
464 + font-size: 1.1rem;
465 + color: var(--color-primary);
466 + opacity: 0.72;
467 + pointer-events: none;
468 + }
469 +
470 + .file-search-input {
471 + width: 100%;
472 + height: 2.4rem;
473 + border: 1px solid var(--color-border);
474 + border-radius: 6px;
475 + background: var(--color-input);
476 + color: var(--color-text);
477 + padding: 0 2.35rem 0 2.2rem;
478 + font: inherit;
479 + transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
480 + }
481 +
482 + .file-search-input:focus {
483 + outline: none;
484 + border-color: var(--color-primary);
485 + background: var(--color-input-focus);
486 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
487 + }
488 +
489 + .file-search-clear {
490 + position: absolute;
491 + right: 0.35rem;
492 + width: 1.65rem;
493 + height: 1.65rem;
494 + color: var(--color-text);
495 + }
496 +
497 .new-item-buttons {
498 display: flex;
499 justify-content: flex-end;
500 gap: 0.5em;
296 - margin-bottom: 0.5em;
501 }
502 .new-item-buttons button {
503 margin-left: 0.5em;
504 }
505 +
506 + .file-status-bar {
507 + border-radius: 8px 8px 0 0;
508 + margin-bottom: calc(-1 * var(--spacing-sm));
509 + }
510 +
511 + .file-mass-toolbar {
512 + border-left: 1px solid var(--color-border);
513 + border-right: 1px solid var(--color-border);
514 + border-bottom: 1px solid var(--color-border);
515 + }
516 +
517 + .file-mass-toolbar .btn-mass:disabled {
518 + opacity: 0.55;
519 + cursor: wait;
520 + }
521
522 .nav-button {
523 padding: 4px 12px;
@@ -362,6 +582,8 @@
582 .file-actions {
583 display: flex;
584 gap: var(--spacing-xs);
585 + justify-content: flex-end;
586 + padding-right: 0.5rem;
587 }
588 .file-actions .dropdown-menu {
589 top: auto !important;
@@ -388,11 +610,26 @@
610 }
611 /* Responsive Design */
612 @media (max-width: 768px) {
613 + .file-browser-toolbar {
614 + align-items: stretch;
615 + flex-direction: column;
616 + }
617 + .file-search-shell {
618 + min-width: 0;
619 + width: 100%;
620 + }
621 + .new-item-buttons {
622 + width: 100%;
623 + }
624 + .new-item-buttons .btn-new-item {
625 + flex: 1;
626 + justify-content: center;
627 + }
628 .file-header {
392 - grid-template-columns: 1fr 0.52fr 120px;
629 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 7.5rem;
630 }
631 .file-item {
395 - grid-template-columns: 1fr 0.5fr 120px;
632 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.5fr) 7.5rem;
633 }
634 .file-cell-date,
635 .file-date {
@@ -402,7 +639,7 @@
639 @media (max-width: 540px) {
640 .file-header,
641 .file-item {
405 - grid-template-columns: 1fr 120px;
642 + grid-template-columns: 2.25rem minmax(0, 1fr) 7.5rem;
643 }
644 .file-cell-size,
645 .file-size,