Add file browser drag and drop

Move files and folders into directories or the parent path from the shared Files surface, with guarded multi-item backend moves and clear drop feedback. Keep drag gestures independent from row selection, preserve unrelated selections after moves, and add focused regression coverage.

Alessandro committed Aug 1, 2026 at 12:50 UTC 2925baca8a037178b0b1f339f27040735817449a
10 files changed +324 -29
api/rename_work_dir_file.py
+57 -24
@@ -9,33 +9,58 @@ class RenameWorkDirFile(ApiHandler):
9 async def process(self, input: Input, request: Request) -> Output:
10 try:
11 action = input.get("action", "rename")
12 - new_name = (input.get("newName", "") or "").strip()
13 - if not new_name:
14 - return {"error": "New name is required"}
15 -
12 current_path = input.get("currentPath", "")
13
18 - if action == "create-folder":
19 - parent_path = input.get("parentPath", current_path)
20 - if not parent_path:
21 - return {"error": "Parent path is required"}
22 - res = await runtime.call_development_function(
23 - create_folder, parent_path, new_name
14 + if action == "move":
15 + file_paths = input.get("paths", [])
16 + destination_path = input.get("destinationPath", "")
17 + if not isinstance(file_paths, list) or not all(
18 + isinstance(path, str) and path for path in file_paths
19 + ):
20 + return {"error": "Paths are required"}
21 + if not isinstance(destination_path, str) or not destination_path:
22 + return {"error": "Destination path is required"}
23 + file_paths = [
24 + path if path.startswith("/") else f"/{path}"
25 + for path in file_paths
26 + ]
27 + if not destination_path.startswith("/"):
28 + destination_path = f"/{destination_path}"
29 + moved_paths = await runtime.call_development_function(
30 + move_items, file_paths, destination_path
31 )
25 - changed_paths = [posixpath.join(str(parent_path).rstrip("/"), new_name)]
32 + res = bool(moved_paths)
33 + changed_paths = [*file_paths, *moved_paths]
34 + elif action in {"rename", "create-folder"}:
35 + new_name = (input.get("newName", "") or "").strip()
36 + if not new_name:
37 + return {"error": "New name is required"}
38 +
39 + if action == "create-folder":
40 + parent_path = input.get("parentPath", current_path)
41 + if not parent_path:
42 + return {"error": "Parent path is required"}
43 + res = await runtime.call_development_function(
44 + create_folder, parent_path, new_name
45 + )
46 + changed_paths = [
47 + posixpath.join(str(parent_path).rstrip("/"), new_name)
48 + ]
49 + else:
50 + file_path = input.get("path", "")
51 + if not file_path:
52 + return {"error": "Path is required"}
53 + if not file_path.startswith("/"):
54 + file_path = f"/{file_path}"
55 + res = await runtime.call_development_function(
56 + rename_item, file_path, new_name
57 + )
58 + changed_paths = [
59 + file_path,
60 + posixpath.join(posixpath.dirname(file_path), new_name),
61 + ]
62 else:
27 - file_path = input.get("path", "")
28 - if not file_path:
29 - return {"error": "Path is required"}
30 - if not file_path.startswith("/"):
31 - file_path = f"/{file_path}"
32 - res = await runtime.call_development_function(
33 - rename_item, file_path, new_name
34 - )
35 - changed_paths = [
36 - file_path,
37 - posixpath.join(posixpath.dirname(file_path), new_name),
38 - ]
63 + return {"error": "Unsupported file operation"}
64
65 if res:
66 await extension.call_extensions_async(
@@ -53,7 +78,10 @@ class RenameWorkDirFile(ApiHandler):
78 )
79 return {"data": result}
80
56 - error_msg = "Failed to create folder" if action == "create-folder" else "Rename failed"
81 + error_msg = {
82 + "create-folder": "Failed to create folder",
83 + "move": "Move failed",
84 + }.get(action, "Rename failed")
85 return {"error": error_msg}
86
87 except Exception as e:
@@ -68,3 +96,8 @@ async def rename_item(file_path: str, new_name: str) -> bool:
96 async def create_folder(parent_path: str, folder_name: str) -> bool:
97 browser = FileBrowser()
98 return browser.create_folder(parent_path, folder_name)
99 +
100 +
101 +async def move_items(file_paths: list[str], destination_path: str) -> list[str]:
102 + browser = FileBrowser()
103 + return browser.move_items(file_paths, destination_path)
api/rename_work_dir_file.py.dox.md
+4 -2
@@ -16,6 +16,7 @@
16 - Top-level functions:
17 - `async rename_item(file_path: str, new_name: str) -> bool`
18 - `async create_folder(parent_path: str, folder_name: str) -> bool`
19 +- `async move_items(file_paths: list[str], destination_path: str) -> list[str]`
20
21 ## Runtime Contracts
22
@@ -28,7 +29,8 @@
29
30 ## Key Concepts
31
31 -- Important called helpers/classes observed in the source: `FileBrowser`, `browser.rename_item`, `browser.create_folder`, `strip`, `runtime.call_development_function`, `posixpath.join`, `file_path.startswith`, `extension.call_extensions_async`, `str.rstrip`, `posixpath.dirname`.
32 +- Important called helpers/classes observed in the source: `FileBrowser`, `browser.rename_item`, `browser.create_folder`, `browser.move_items`, `strip`, `runtime.call_development_function`, `posixpath.join`, `file_path.startswith`, `extension.call_extensions_async`, `str.rstrip`, `posixpath.dirname`.
33 +- The `move` action accepts `paths` plus `destinationPath`, emits the standard mutation hook, and returns the refreshed `currentPath` listing used by drag-and-drop clients.
34 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
35
36 ## Work Guidance
@@ -40,7 +42,7 @@
42 ## Verification
43
44 - Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists.
43 -- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check.
45 +- Run `pytest tests/test_file_browser_navigation.py` and smoke-test folder and Up-button drops in the WebUI.
46
47 ## Child DOX Index
48
helpers/file_browser.py
+56
@@ -138,6 +138,62 @@ class FileBrowser:
138 PrintStyle.error(f"Error renaming {file_path}: {e}")
139 raise
140
141 + def move_items(self, file_paths: List[str], destination_path: str) -> List[str]:
142 + if not file_paths:
143 + raise ValueError("No items selected")
144 +
145 + base_dir = self.base_dir.resolve()
146 + destination = (self.base_dir / destination_path).resolve()
147 + if not destination.is_relative_to(base_dir):
148 + raise ValueError("Invalid destination path")
149 + if not destination.is_dir():
150 + raise NotADirectoryError("Destination folder not found")
151 +
152 + moves: List[Tuple[Path, Path]] = []
153 + targets: set[Path] = set()
154 + for file_path in dict.fromkeys(file_paths):
155 + requested = self.base_dir / file_path
156 + source = requested.parent.resolve() / requested.name
157 + if not source.is_relative_to(base_dir) or source == base_dir:
158 + raise ValueError("Invalid source path")
159 + if not source.exists() and not source.is_symlink():
160 + raise FileNotFoundError(f"Item not found: {source.name}")
161 + if source == destination:
162 + raise ValueError("A folder cannot be moved into itself")
163 + if (
164 + source.is_dir()
165 + and not source.is_symlink()
166 + and destination.is_relative_to(source)
167 + ):
168 + raise ValueError("A folder cannot be moved into itself")
169 +
170 + target = destination / source.name
171 + if target == source:
172 + raise ValueError(f"{source.name} is already in this folder")
173 + if target.exists() or target.is_symlink():
174 + raise FileExistsError(
175 + f'An item named "{source.name}" already exists'
176 + )
177 + if target in targets:
178 + raise FileExistsError(f'Multiple items are named "{source.name}"')
179 + targets.add(target)
180 + moves.append((source, target))
181 +
182 + moved: List[Tuple[Path, Path]] = []
183 + try:
184 + for source, target in moves:
185 + os.rename(source, target)
186 + moved.append((source, target))
187 + except Exception:
188 + for source, target in reversed(moved):
189 + try:
190 + os.rename(target, source)
191 + except Exception as rollback_error:
192 + PrintStyle.error(f"Error restoring {source}: {rollback_error}")
193 + raise
194 +
195 + return [str(target) for _, target in moved]
196 +
197 def create_folder(self, parent_path: str, folder_name: str) -> bool:
198 try:
199 if not folder_name or folder_name in {".", ".."}:
helpers/file_browser.py.dox.md
+3
@@ -16,6 +16,7 @@
16 - `save_files(self, files: List, current_path: str=...) -> Tuple[List[str], List[str]]`
17 - `delete_file(self, file_path: str) -> bool`
18 - `rename_item(self, file_path: str, new_name: str) -> bool`
19 + - `move_items(self, file_paths: List[str], destination_path: str) -> List[str]`
20 - `create_folder(self, parent_path: str, folder_name: str) -> bool`
21 - `save_text_file(self, file_path: str, content: str) -> bool`
22 - `get_files(self, current_path: str=...) -> Dict`
@@ -31,6 +32,7 @@
32 ## Key Concepts
33
34 - Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `self._get_file_extension`, `file.seek`, `file.tell`, `resolve`, `os.makedirs`, `os.path.exists`, `full_path.with_name`, `new_path.exists`, `os.rename`, `target_dir.exists`, `filename.rsplit.lower`, `subprocess.run`, `result.stdout.strip.split`, `self._get_files_via_ls`, `files.exists`, `ValueError`, `str.startswith`, `file.write`.
35 +- Multi-item moves validate every source and target before renaming, reject collisions and directory self-nesting, preserve symlink objects, and best-effort roll back earlier renames if a later rename fails.
36 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
37
38 ## Work Guidance
@@ -45,6 +47,7 @@
47 - Related tests observed by source search:
48 - `tests/test_download_toast_regressions.py`
49 - `tests/test_office_document_store.py`
50 + - `tests/test_file_browser_navigation.py`
51
52 ## Child DOX Index
53
tests/test_file_browser_navigation.py
+54
@@ -1,6 +1,8 @@
1 from pathlib import Path
2 import sys
3
4 +import pytest
5 +
6
7 PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 if str(PROJECT_ROOT) not in sys.path:
@@ -219,3 +221,55 @@ def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
221 assert result["entries"] == []
222 assert result["current_path"] == str(missing_directory)
223 assert result["error"] == "Directory not found"
224 +
225 +
226 +def test_file_browser_moves_selected_items_without_overwriting_or_self_nesting(tmp_path: Path) -> None:
227 + browser = FileBrowser()
228 + browser.base_dir = tmp_path
229 + source_file = tmp_path / "note.md"
230 + source_folder = tmp_path / "skills"
231 + destination = tmp_path / "archive"
232 + source_file.write_text("hello", encoding="utf-8")
233 + source_folder.mkdir()
234 + destination.mkdir()
235 +
236 + moved = browser.move_items(["note.md", "skills"], "archive")
237 +
238 + assert moved == [str(destination / "note.md"), str(destination / "skills")]
239 + assert (destination / "note.md").read_text(encoding="utf-8") == "hello"
240 + assert (destination / "skills").is_dir()
241 +
242 + collision = tmp_path / "collision.md"
243 + collision.write_text("source", encoding="utf-8")
244 + (destination / "collision.md").write_text("keep", encoding="utf-8")
245 + with pytest.raises(FileExistsError, match="already exists"):
246 + browser.move_items(["collision.md"], "archive")
247 + assert collision.read_text(encoding="utf-8") == "source"
248 + assert (destination / "collision.md").read_text(encoding="utf-8") == "keep"
249 +
250 + nested = destination / "skills" / "nested"
251 + nested.mkdir()
252 + with pytest.raises(ValueError, match="cannot be moved into itself"):
253 + browser.move_items(["archive/skills"], "archive/skills/nested")
254 +
255 +
256 +def test_file_browser_drag_and_drop_contract() -> None:
257 + html = read("webui", "components", "modals", "file-browser", "file-browser.html")
258 + store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
259 + attachments = read("webui", "components", "chat", "attachments", "attachmentsStore.js")
260 + api = read("api", "rename_work_dir_file.py")
261 +
262 + assert ':draggable="!$store.fileBrowser.isPickerMode() && !$store.fileBrowser.isBulkBusy"' in html
263 + assert "$store.fileBrowser.dropItems(file.path, file.name, $event)" in html
264 + assert "$store.fileBrowser.dropItems($store.fileBrowser.browser.parentPath, 'parent folder', $event)" in html
265 + start_drag = store[store.index(" startDrag("):store.index(" isDraggingPath(")]
266 + assert "this.clearSelection()" not in start_drag
267 + assert "file.selected = true" not in start_drag
268 + assert ": [file.path]" in start_drag
269 + assert "decorateEntries(data.data?.entries || [], selectedPaths)" in store
270 + assert "application/x-agent-zero-files" in store
271 + assert 'action: "move"' in store
272 + assert 'fetchApi("/rename_work_dir_file"' in store
273 + assert 'if action == "move":' in api
274 + assert 'isExternalFileDrag(event)' in attachments
275 + assert 'includes("Files")' in attachments
webui/components/chat/AGENTS.md
+1
@@ -28,6 +28,7 @@
28 - The top-section project selector, clock, and connection indicator must respect the instance-level mobile/desktop visibility preferences.
29 - While the selected context is running, an empty composer makes the primary button stop the active run; typed text still adds to the queue, and Enter with an empty composer still sends queued messages.
30 - Chat navigation controls must cross virtual message-window boundaries; top and bottom target the full cached history rather than only the mounted DOM slice.
31 +- The page-wide attachment drop overlay must activate only for external file drags so internal WebUI drag interactions keep their own targets.
32
33 ## Work Guidance
34
webui/components/chat/attachments/attachmentsStore.js
+8
@@ -66,6 +66,10 @@ const model = {
66 this.dragDropOverlayVisible = false;
67 },
68
69 + isExternalFileDrag(event) {
70 + return Array.from(event?.dataTransfer?.types || []).includes("Files");
71 + },
72 +
73 // Setup drag and drop event handlers
74 setupDragDropHandlers() {
75 console.log("Setting up drag and drop handlers...");
@@ -76,6 +80,7 @@ const model = {
80 document.addEventListener(
81 eventName,
82 (e) => {
83 + if (!this.isExternalFileDrag(e)) return;
84 e.preventDefault();
85 e.stopPropagation();
86 },
@@ -87,6 +92,7 @@ const model = {
92 document.addEventListener(
93 "dragenter",
94 (e) => {
95 + if (!this.isExternalFileDrag(e)) return;
96 console.log("Drag enter detected");
97 dragCounter++;
98 if (dragCounter === 1) {
@@ -101,6 +107,7 @@ const model = {
107 document.addEventListener(
108 "dragleave",
109 (e) => {
110 + if (!this.isExternalFileDrag(e) && dragCounter === 0) return;
111 dragCounter--;
112 if (dragCounter === 0) {
113 this.hideDragDropOverlay();
@@ -113,6 +120,7 @@ const model = {
120 document.addEventListener(
121 "drop",
122 async (e) => {
123 + if (!this.isExternalFileDrag(e)) return;
124 const dataTransfer = e.dataTransfer;
125 console.log("Drop detected with files:", dataTransfer?.files?.length || 0);
126 dragCounter = 0;
webui/components/modals/file-browser/AGENTS.md
+1
@@ -25,6 +25,7 @@
25 - Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
26 - Keep narrow mobile controls compact: Up shares the path row, and New file/New folder share the search row.
27 - Preserve surface actions that route supported files to Browser, Desktop, or Editor.
28 +- Keep native drag moves available outside picker modes: dragging an unselected row moves only that row without changing selection, dragging a selected row moves the selection, folder rows accept drops, and Up moves items to the parent directory. Moves must reject overwrites and self-nesting.
29
30 ## Work Guidance
31
webui/components/modals/file-browser/file-browser-store.js
+88
@@ -92,6 +92,8 @@ const model = {
92 dropdownStyle: {},
93 searchQuery: "",
94 isBulkBusy: false,
95 + draggedPaths: [],
96 + dragOverPath: "",
97 pickerMode: PICKER_MODE_NONE,
98 pickerConfirmLabel: "",
99 pickerFilename: "",
@@ -210,6 +212,7 @@ const model = {
212 this.openDropdownPath = null;
213 this.searchQuery = "";
214 this.isBulkBusy = false;
215 + this.clearDragState();
216 this.pathInput = "";
217 this.pathError = "";
218 this.isPathSubmitting = false;
@@ -260,6 +263,7 @@ const model = {
263 this.history = [];
264 this.searchQuery = "";
265 this.isBulkBusy = false;
266 + this.clearDragState();
267 this.pathError = "";
268 this.isPathSubmitting = false;
269 this.configurePicker(options);
@@ -951,6 +955,90 @@ const model = {
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();
webui/components/modals/file-browser/file-browser.html
+52 -3
@@ -41,6 +41,10 @@
41 type="button"
42 class="nav-button back-button"
43 @click="$store.fileBrowser.navigateUp()"
44 + @dragover="$store.fileBrowser.setDropTarget($store.fileBrowser.browser.parentPath, $event)"
45 + @dragleave="$store.fileBrowser.clearDropTarget($store.fileBrowser.browser.parentPath, $event)"
46 + @drop="$store.fileBrowser.dropItems($store.fileBrowser.browser.parentPath, 'parent folder', $event)"
47 + :class="{ 'is-drop-target': $store.fileBrowser.dragOverPath === $store.fileBrowser.browser.parentPath }"
48 :disabled="!$store.fileBrowser.browser.parentPath || $store.fileBrowser.isLoading"
49 aria-label="Navigate up"
50 title="Navigate up"
@@ -216,7 +220,21 @@
220 <!-- File list entries -->
221 <template x-if="$store.fileBrowser.visibleEntries.length">
222 <template x-for="file in $store.fileBrowser.visibleEntries" :key="file.path">
219 - <div class="file-item" :data-is-dir="file.is_dir" :class="{ 'selected': file.selected }">
223 + <div
224 + class="file-item"
225 + :data-is-dir="file.is_dir"
226 + :draggable="!$store.fileBrowser.isPickerMode() && !$store.fileBrowser.isBulkBusy"
227 + :class="{
228 + 'selected': file.selected,
229 + 'is-dragging': $store.fileBrowser.isDraggingPath(file.path),
230 + 'is-drop-target': file.is_dir && $store.fileBrowser.dragOverPath === file.path
231 + }"
232 + @dragstart="$store.fileBrowser.startDrag(file, $event)"
233 + @dragend="$store.fileBrowser.clearDragState()"
234 + @dragover="file.is_dir && $store.fileBrowser.setDropTarget(file.path, $event)"
235 + @dragleave="file.is_dir && $store.fileBrowser.clearDropTarget(file.path, $event)"
236 + @drop="file.is_dir && $store.fileBrowser.dropItems(file.path, file.name, $event)"
237 + >
238 <label class="file-select-cell" @click.stop>
239 <input
240 type="checkbox"
@@ -226,7 +244,7 @@
244 />
245 </label>
246 <div class="file-name" @click="$store.fileBrowser.handleFileNameClick(file)">
229 - <img :src="'/public/' + (file.type === 'unknown' ? 'file' : ($store.fileBrowser.isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type" />
247 + <img :src="'/public/' + (file.type === 'unknown' ? 'file' : ($store.fileBrowser.isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type" draggable="false" />
248 <span x-text="file.name"></span>
249 </div>
250 <div class="file-size" x-text="$store.fileBrowser.formatFileSize(file.size)"></div>
@@ -597,7 +615,7 @@
615 padding: 8px 0;
616 font-size: 0.875rem;
617 border-top: 1px solid var(--color-border);
600 - transition: background-color 0.2s;
618 + transition: background-color 120ms cubic-bezier(0.2, 0, 0, 1), box-shadow 120ms cubic-bezier(0.2, 0, 0, 1), opacity 120ms ease, transform 120ms cubic-bezier(0.2, 0, 0, 1);
619 white-space: nowrap;
620 border-radius: 4px;
621 overflow: visible; /* allow action dropdown menus to overflow the row */
@@ -609,6 +627,19 @@
627 .file-item.selected {
628 background: color-mix(in srgb, var(--color-primary) 12%, transparent);
629 }
630 + .file-item.is-dragging {
631 + opacity: 0.45;
632 + transform: scale(0.995);
633 + }
634 + .file-item.is-drop-target,
635 + .nav-button.is-drop-target {
636 + background: color-mix(in srgb, var(--color-primary) 18%, var(--color-input) 82%);
637 + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--color-primary) 72%, transparent);
638 + }
639 + .file-item.is-drop-target .file-icon,
640 + .nav-button.is-drop-target .material-symbols-outlined {
641 + transform: scale(1.12);
642 + }
643
644 .file-select-cell {
645 display: flex;
@@ -633,6 +664,7 @@
664 margin: 0 1rem 0 0.7rem;
665 vertical-align: middle;
666 font-size: var(--font-size-sm);
667 + transition: transform 120ms cubic-bezier(0.2, 0, 0, 1);
668 }
669 .file-name {
670 display: flex;
@@ -848,6 +880,7 @@
880 .nav-button .material-symbols-outlined {
881 font-size: 1.05rem;
882 line-height: 1;
883 + transition: transform 120ms cubic-bezier(0.2, 0, 0, 1);
884 }
885 .nav-button-label {
886 font-size: 0.66rem;
@@ -873,9 +906,18 @@
906 .file-item[data-is-dir="true"] {
907 cursor: pointer;
908 }
909 + .file-item[draggable="true"] {
910 + cursor: grab;
911 + }
912 + .file-item[draggable="true"]:active {
913 + cursor: grabbing;
914 + }
915 .file-item[data-is-dir="true"]:hover {
916 background-color: var(--color-secondary);
917 }
918 + .file-item[data-is-dir="true"].is-drop-target:hover {
919 + background: color-mix(in srgb, var(--color-primary) 18%, var(--color-input) 82%);
920 + }
921
922 /* Upload Button Styles */
923 .btn-upload {
@@ -1100,6 +1142,13 @@
1142 flex: 1 1 auto;
1143 }
1144 }
1145 + @media (prefers-reduced-motion: reduce) {
1146 + .file-item,
1147 + .file-icon,
1148 + .nav-button .material-symbols-outlined {
1149 + transition: none;
1150 + }
1151 + }
1152 </style>
1153 </body>
1154 </html>