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>