file/dir renaming, file actions dropdown, extend APIs
3clyp50 committed
Jan 25, 2026 at 16:46 UTC
7623b5a7a6617d463f176d838787950dd6233fc3
8 files changed
+660
-41
python/api/delete_work_dir_file.py
+19
-16
@@ -8,22 +8,25 @@ from python.api import get_work_dir_files
8
9
class DeleteWorkDirFile(ApiHandler):
10
async def process(self, input: Input, request: Request) -> Output:
11
- file_path = input.get("path", "")
12
- if not file_path.startswith("/"):
13
- file_path = f"/{file_path}"
14
-
15
- current_path = input.get("currentPath", "")
16
-
17
- # browser = FileBrowser()
18
- res = await runtime.call_development_function(delete_file, file_path)
19
-
20
- if res:
21
- # Get updated file list
22
- # result = browser.get_files(current_path)
23
- result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
24
- return {"data": result}
25
- else:
26
- raise Exception("File not found or could not be deleted")
11
+ try:
12
+ file_path = input.get("path", "")
13
+ if not file_path.startswith("/"):
14
+ file_path = f"/{file_path}"
15
+
16
+ current_path = input.get("currentPath", "")
17
+
18
+ # browser = FileBrowser()
19
+ res = await runtime.call_development_function(delete_file, file_path)
20
+
21
+ if res:
22
+ # Get updated file list
23
+ # result = browser.get_files(current_path)
24
+ result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
25
+ return {"data": result}
26
+ else:
27
+ return {"error": "File not found or could not be deleted"}
28
+ except Exception as e:
29
+ return {"error": str(e)}
30
31
32
async def delete_file(file_path: str):
python/api/edit_work_dir_file.py
new
+84
@@ -0,0 +1,84 @@
1
+import mimetypes
2
+import os
3
+
4
+from python.helpers.api import ApiHandler, Input, Output, Request
5
+from python.helpers.file_browser import FileBrowser
6
+from python.helpers import runtime, files
7
+
8
+MAX_EDIT_FILE_SIZE = 1024 * 1024
9
+BINARY_SAMPLE_SIZE = 10 * 1024
10
+
11
+
12
+class EditWorkDirFile(ApiHandler):
13
+ @classmethod
14
+ def get_methods(cls):
15
+ return ["GET", "POST"]
16
+
17
+ async def process(self, input: Input, request: Request) -> Output:
18
+ try:
19
+ if request.method == "GET":
20
+ file_path = request.args.get("path", "")
21
+ if not file_path:
22
+ return {"error": "Path is required"}
23
+ if not file_path.startswith("/"):
24
+ file_path = f"/{file_path}"
25
+
26
+ data = await runtime.call_development_function(load_file, file_path)
27
+ return {"data": data}
28
+
29
+ file_path = input.get("path", "")
30
+ if not file_path:
31
+ return {"error": "Path is required"}
32
+ if not file_path.startswith("/"):
33
+ file_path = f"/{file_path}"
34
+
35
+ content = input.get("content", "")
36
+ if not isinstance(content, str):
37
+ return {"error": "Content must be a string"}
38
+
39
+ content_size = len(content.encode("utf-8"))
40
+ if content_size > MAX_EDIT_FILE_SIZE:
41
+ return {"error": "File exceeds 1 MB and cannot be edited"}
42
+
43
+ res = await runtime.call_development_function(save_file, file_path, content)
44
+ if not res:
45
+ return {"error": "Failed to save file"}
46
+
47
+ return {"ok": True}
48
+ except Exception as e:
49
+ return {"error": str(e)}
50
+
51
+
52
+async def load_file(file_path: str) -> dict:
53
+ browser = FileBrowser()
54
+ full_path = browser.get_full_path(file_path)
55
+
56
+ if os.path.isdir(full_path):
57
+ raise Exception("Path points to a directory")
58
+
59
+ size = os.path.getsize(full_path)
60
+ if size > MAX_EDIT_FILE_SIZE:
61
+ raise Exception("File exceeds 1 MB and cannot be edited")
62
+
63
+ # Binary detection: only sample the first ~10KB (per backend rules)
64
+ if files.is_probably_binary_file(full_path, sample_size=BINARY_SAMPLE_SIZE):
65
+ raise Exception("Binary file detected; editing is not supported")
66
+
67
+ mime_type, _ = mimetypes.guess_type(full_path)
68
+ try:
69
+ with open(full_path, "r", encoding="utf-8", errors="strict") as file:
70
+ content = file.read()
71
+ except UnicodeDecodeError:
72
+ raise Exception("Unable to decode file as UTF-8; editing is not supported")
73
+
74
+ return {
75
+ "path": file_path,
76
+ "name": os.path.basename(full_path),
77
+ "mime_type": mime_type or "text/plain",
78
+ "content": content,
79
+ }
80
+
81
+
82
+def save_file(file_path: str, content: str) -> bool:
83
+ browser = FileBrowser()
84
+ return browser.save_text_file(file_path, content)
python/api/rename_work_dir_file.py
new
+54
@@ -0,0 +1,54 @@
1
+from python.helpers.api import ApiHandler, Input, Output, Request
2
+from python.helpers.file_browser import FileBrowser
3
+from python.helpers import runtime
4
+from python.api import get_work_dir_files
5
+
6
+
7
+class RenameWorkDirFile(ApiHandler):
8
+ async def process(self, input: Input, request: Request) -> Output:
9
+ try:
10
+ action = input.get("action", "rename")
11
+ new_name = (input.get("newName", "") or "").strip()
12
+ if not new_name:
13
+ return {"error": "New name is required"}
14
+
15
+ current_path = input.get("currentPath", "")
16
+
17
+ if action == "create-folder":
18
+ parent_path = input.get("parentPath", current_path)
19
+ if not parent_path:
20
+ return {"error": "Parent path is required"}
21
+ res = await runtime.call_development_function(
22
+ create_folder, parent_path, new_name
23
+ )
24
+ else:
25
+ file_path = input.get("path", "")
26
+ if not file_path:
27
+ return {"error": "Path is required"}
28
+ if not file_path.startswith("/"):
29
+ file_path = f"/{file_path}"
30
+ res = await runtime.call_development_function(
31
+ rename_item, file_path, new_name
32
+ )
33
+
34
+ if res:
35
+ result = await runtime.call_development_function(
36
+ get_work_dir_files.get_files, current_path
37
+ )
38
+ return {"data": result}
39
+
40
+ error_msg = "Failed to create folder" if action == "create-folder" else "Rename failed"
41
+ return {"error": error_msg}
42
+
43
+ except Exception as e:
44
+ return {"error": str(e)}
45
+
46
+
47
+async def rename_item(file_path: str, new_name: str) -> bool:
48
+ browser = FileBrowser()
49
+ return browser.rename_item(file_path, new_name)
50
+
51
+
52
+async def create_folder(parent_path: str, folder_name: str) -> bool:
53
+ browser = FileBrowser()
54
+ return browser.create_folder(parent_path, folder_name)
python/helpers/file_browser.py
+73
@@ -19,6 +19,7 @@ class FileBrowser:
19
}
20
21
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
22
+ MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024 # 1MB
23
24
def __init__(self):
25
# if runtime.is_development():
@@ -107,6 +108,78 @@ class FileBrowser:
108
PrintStyle.error(f"Error deleting {file_path}: {e}")
109
return False
110
111
+ def rename_item(self, file_path: str, new_name: str) -> bool:
112
+ try:
113
+ if not new_name or new_name in {".", ".."}:
114
+ raise ValueError("Invalid new name")
115
+ if "/" in new_name or "\\" in new_name:
116
+ raise ValueError("New name cannot include path separators")
117
+
118
+ full_path = (self.base_dir / file_path).resolve()
119
+ if not str(full_path).startswith(str(self.base_dir)):
120
+ raise ValueError("Invalid path")
121
+ if not full_path.exists():
122
+ raise FileNotFoundError("File or folder not found")
123
+
124
+ new_path = full_path.with_name(new_name)
125
+ if not str(new_path).startswith(str(self.base_dir)):
126
+ raise ValueError("Invalid target path")
127
+ if full_path == new_path:
128
+ return True
129
+ if new_path.exists():
130
+ raise FileExistsError("Target already exists")
131
+
132
+ os.rename(full_path, new_path)
133
+ return True
134
+ except Exception as e:
135
+ PrintStyle.error(f"Error renaming {file_path}: {e}")
136
+ raise
137
+
138
+ def create_folder(self, parent_path: str, folder_name: str) -> bool:
139
+ try:
140
+ if not folder_name or folder_name in {".", ".."}:
141
+ raise ValueError("Invalid folder name")
142
+ if "/" in folder_name or "\\" in folder_name:
143
+ raise ValueError("Folder name cannot include path separators")
144
+
145
+ parent_full = (self.base_dir / parent_path).resolve()
146
+ if not str(parent_full).startswith(str(self.base_dir)):
147
+ raise ValueError("Invalid parent path")
148
+
149
+ target_dir = (parent_full / folder_name).resolve()
150
+ if not str(target_dir).startswith(str(self.base_dir)):
151
+ raise ValueError("Invalid target path")
152
+ if target_dir.exists():
153
+ raise FileExistsError("Folder already exists")
154
+
155
+ os.makedirs(target_dir, exist_ok=False)
156
+ return True
157
+ except Exception as e:
158
+ PrintStyle.error(f"Error creating folder {folder_name}: {e}")
159
+ raise
160
+
161
+ def save_text_file(self, file_path: str, content: str) -> bool:
162
+ try:
163
+ if not isinstance(content, str):
164
+ raise ValueError("Content must be a string")
165
+ content_size = len(content.encode("utf-8"))
166
+ if content_size > self.MAX_TEXT_FILE_SIZE:
167
+ raise ValueError("File exceeds 1 MB and cannot be edited")
168
+
169
+ full_path = (self.base_dir / file_path).resolve()
170
+ if not str(full_path).startswith(str(self.base_dir)):
171
+ raise ValueError("Invalid path")
172
+ if full_path.exists() and full_path.is_dir():
173
+ raise ValueError("Target is a directory")
174
+
175
+ os.makedirs(full_path.parent, exist_ok=True)
176
+ with open(full_path, "w", encoding="utf-8") as file:
177
+ file.write(content)
178
+ return True
179
+ except Exception as e:
180
+ PrintStyle.error(f"Error saving file {file_path}: {e}")
181
+ raise
182
+
183
def _is_allowed_file(self, filename: str, file) -> bool:
184
# allow any file to be uploaded in file browser
185
python/helpers/files.py
+36
@@ -230,6 +230,42 @@ def read_file_base64(relative_path):
230
return base64.b64encode(f.read()).decode("utf-8")
231
232
233
+def is_probably_binary_bytes(data: bytes, threshold: float = 0.3) -> bool:
234
+ """
235
+ Binary detection.
236
+
237
+ - Fast path: NUL bytes => binary
238
+ - Otherwise: treat high ratio of suspicious ASCII control bytes as binary.
239
+ (We intentionally do NOT treat bytes >= 0x80 as binary to avoid false
240
+ positives for UTF-8 text.)
241
+ """
242
+ if not data:
243
+ return False
244
+ if b"\x00" in data:
245
+ return True
246
+
247
+ # Count suspicious control bytes
248
+ allowed = {8, 9, 10, 12, 13} # \b \t \n \f \r
249
+ suspicious = sum(
250
+ 1
251
+ for b in data
252
+ if ((b < 32 and b not in allowed) or b == 127)
253
+ )
254
+ return (suspicious / len(data)) > threshold
255
+
256
+
257
+def is_probably_binary_file(
258
+ file_path: str, sample_size: int = 10 * 1024, threshold: float = 0.3
259
+) -> bool:
260
+ """Binary detection by reading only the first ~sample_size bytes of a file."""
261
+ try:
262
+ with open(file_path, "rb") as f:
263
+ sample = f.read(sample_size)
264
+ except (FileNotFoundError, PermissionError, OSError):
265
+ raise OSError(f"Unable to read file for binary detection: {file_path}")
266
+ return is_probably_binary_bytes(sample, threshold=threshold)
267
+
268
+
269
def replace_placeholders_text(_content: str, **kwargs):
270
# Replace placeholders with values from kwargs
271
for key, value in kwargs.items():
webui/components/modals/file-browser/file-browser-store.js
+205
-16
@@ -1,5 +1,6 @@
1
import { createStore } from "/js/AlpineStore.js";
2
import { fetchApi } from "/js/api.js";
3
+import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
4
5
// Model migrated from legacy file_browser.js (lift-and-shift)
6
const model = {
@@ -17,6 +18,11 @@ const model = {
18
initialPath: "", // Store path for open() call
19
closePromise: null,
20
error: null,
21
+ renameTarget: null,
22
+ renameName: "",
23
+ renameMode: "rename",
24
+ isRenaming: false,
25
+ renameError: null,
26
27
// --- Lifecycle -----------------------------------------------------------
28
init() {
@@ -36,13 +42,6 @@ const model = {
42
"modals/file-browser/file-browser.html"
43
);
44
39
- // // Setup cleanup on modal close
40
- // if (this.closePromise && typeof this.closePromise.then === "function") {
41
- // this.closePromise.then(() => {
42
- // this.destroy();
43
- // });
44
- // }
45
-
45
// Use stored initial path or default
46
path = path || this.initialPath || this.browser.currentPath || "$WORK_DIR";
47
this.browser.currentPath = path;
@@ -72,6 +71,7 @@ const model = {
71
this.history = [];
72
this.initialPath = "";
73
this.browser.entries = [];
74
+ this.resetRenameState();
75
},
76
77
// --- Helpers -------------------------------------------------------------
@@ -81,6 +81,39 @@ const model = {
81
return archiveExts.includes(ext);
82
},
83
84
+ saveScrollPosition() {
85
+ // Find the file browser modal's scrollable container
86
+ // We look for the modal containing .file-browser-root to target the correct modal
87
+ const fileBrowserRoot = document.querySelector('.file-browser-root');
88
+ if (fileBrowserRoot) {
89
+ const modalScroll = fileBrowserRoot.closest('.modal-scroll');
90
+ if (modalScroll) {
91
+ return {
92
+ scrollTop: modalScroll.scrollTop,
93
+ scrollLeft: modalScroll.scrollLeft
94
+ };
95
+ }
96
+ }
97
+ return null;
98
+ },
99
+
100
+ restoreScrollPosition(scrollPos) {
101
+ if (!scrollPos) return;
102
+
103
+ const restore = () => {
104
+ const fileBrowserRoot = document.querySelector('.file-browser-root');
105
+ if (fileBrowserRoot) {
106
+ const modalScroll = fileBrowserRoot.closest('.modal-scroll');
107
+ if (modalScroll) {
108
+ modalScroll.scrollTop = scrollPos.scrollTop;
109
+ modalScroll.scrollLeft = scrollPos.scrollLeft;
110
+ }
111
+ }
112
+ };
113
+
114
+ requestAnimationFrame(() => requestAnimationFrame(restore));
115
+ },
116
+
117
formatFileSize(size) {
118
if (size === 0) return "0 Bytes";
119
const k = 1024;
@@ -100,6 +133,27 @@ const model = {
133
return new Date(dateString).toLocaleDateString(undefined, options);
134
},
135
136
+ // --- Modal helpers -------------------------------------------------------
137
+ normalizePath(path) {
138
+ if (!path) return "";
139
+ return path.startsWith("/") ? path : `/${path}`;
140
+ },
141
+
142
+ buildChildPath(name) {
143
+ const base = this.normalizePath(this.browser.currentPath || "");
144
+ const trimmedBase = base.replace(/\/$/, "");
145
+ if (!trimmedBase) return `/${name}`;
146
+ return `${trimmedBase}/${name}`;
147
+ },
148
+
149
+ resetRenameState() {
150
+ this.renameTarget = null;
151
+ this.renameName = "";
152
+ this.renameMode = "rename";
153
+ this.isRenaming = false;
154
+ this.renameError = null;
155
+ },
156
+
157
// --- Sorting -------------------------------------------------------------
158
toggleSort(column) {
159
if (this.browser.sortBy === column) {
@@ -132,18 +186,36 @@ const model = {
186
// --- Navigation ----------------------------------------------------------
187
async fetchFiles(path = "") {
188
this.isLoading = true;
189
+
190
+ // Preserve scroll position if refreshing the same path
191
+ const isSamePath = this.browser.currentPath === path ||
192
+ (!path && !this.browser.currentPath);
193
+ const scrollPos = isSamePath ? this.saveScrollPosition() : null;
194
+
195
try {
196
const response = await fetchApi(
197
`/get_work_dir_files?path=${encodeURIComponent(path)}`
198
);
139
- if (response.ok) {
140
- const data = await response.json();
199
+ const data = await response.json().catch(() => ({}));
200
+
201
+ if (response.ok && !data.error) {
202
this.browser.entries = data.data.entries;
203
this.browser.currentPath = data.data.current_path;
204
this.browser.parentPath = data.data.parent_path;
205
+
206
+ // Set isLoading to false BEFORE restoring scroll to avoid reactivity issues
207
+ this.isLoading = false;
208
+
209
+ // Restore scroll position if on same path
210
+ if (scrollPos) {
211
+ this.restoreScrollPosition(scrollPos);
212
+ }
213
} else {
145
- console.error("Error fetching files:", await response.text());
214
+ const msg = data.error || "Error fetching files";
215
+ console.error("Error fetching files:", msg);
216
this.browser.entries = [];
217
+ this.isLoading = false;
218
+ window.toastFrontendError(msg, "File Browser Error");
219
}
220
} catch (e) {
221
window.toastFrontendError(
@@ -151,7 +223,6 @@ const model = {
223
"File Browser Error"
224
);
225
this.browser.entries = [];
154
- } finally {
226
this.isLoading = false;
227
}
228
},
@@ -170,6 +241,123 @@ const model = {
241
}
242
},
243
244
+ // --- Rename / Create -----------------------------------------------------
245
+ async openRenameModal(file) {
246
+ this.resetRenameState();
247
+ this.renameTarget = file;
248
+ this.renameName = file?.name || "";
249
+ this.renameMode = "rename";
250
+ this.renameError = null;
251
+ window.openModal("modals/file-browser/rename-modal.html");
252
+ },
253
+
254
+ async openNewFolderModal() {
255
+ this.resetRenameState();
256
+ this.renameMode = "create-folder";
257
+ this.renameName = "";
258
+ this.renameError = null;
259
+ window.openModal("modals/file-browser/rename-modal.html");
260
+ },
261
+
262
+ closeRenameModal() {
263
+ window.closeModal("modals/file-browser/rename-modal.html");
264
+ },
265
+
266
+ async confirmRename() {
267
+ if (this.isRenaming) return;
268
+
269
+ const newName = this.renameName.trim();
270
+ if (!newName) {
271
+ this.renameError = "Name is required.";
272
+ return;
273
+ }
274
+ if (newName === "." || newName === "..") {
275
+ this.renameError = "Name cannot be '.' or '..'.";
276
+ return;
277
+ }
278
+ if (newName.includes("/") || newName.includes("\\")) {
279
+ this.renameError = "Name cannot include path separators.";
280
+ return;
281
+ }
282
+ if (this.renameMode !== "create-folder" && !this.renameTarget?.path) {
283
+ this.renameError = "No item selected for rename.";
284
+ return;
285
+ }
286
+
287
+ // UX: pre-validate duplicates so we can show a clean inline error (no toast spam)
288
+ const duplicate = (this.browser.entries || []).some((entry) => {
289
+ if (!entry?.name) return false;
290
+ if (entry.name !== newName) return false;
291
+ // When renaming, allow keeping the same entry name
292
+ if (this.renameTarget?.path && entry.path === this.renameTarget.path) return false;
293
+ return true;
294
+ });
295
+ if (duplicate) {
296
+ this.renameError = `An item named "${newName}" already exists.`;
297
+ return;
298
+ }
299
+
300
+ this.isRenaming = true;
301
+ this.renameError = null;
302
+
303
+ try {
304
+ const payload =
305
+ this.renameMode === "create-folder"
306
+ ? {
307
+ action: "create-folder",
308
+ parentPath: this.browser.currentPath,
309
+ currentPath: this.browser.currentPath,
310
+ newName: newName,
311
+ }
312
+ : {
313
+ action: "rename",
314
+ path: this.renameTarget?.path,
315
+ currentPath: this.browser.currentPath,
316
+ newName: newName,
317
+ };
318
+
319
+ const resp = await fetchApi("/rename_work_dir_file", {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/json" },
322
+ body: JSON.stringify(payload),
323
+ });
324
+
325
+ const data = await resp.json().catch(() => ({}));
326
+ if (!resp.ok || data.error) {
327
+ throw new Error(data.error || "Rename failed");
328
+ }
329
+
330
+ await this.fetchFiles(this.browser.currentPath);
331
+ this.closeRenameModal();
332
+ } catch (error) {
333
+ const message = error?.message || "Rename failed";
334
+ this.renameError = message;
335
+ const title =
336
+ this.renameMode === "create-folder" ? "Folder Error" : "Rename Error";
337
+ window.toastFrontendError(message, title);
338
+ } finally {
339
+ this.isRenaming = false;
340
+ }
341
+ },
342
+
343
+ // --- File Editor (Delegated to FileEditorStore) --------------------------
344
+ async openFileEditor(file) {
345
+ await fileEditorStore.openFile(file, async () => {
346
+ // Callback on successful save to refresh file list
347
+ await this.fetchFiles(this.browser.currentPath);
348
+ });
349
+ },
350
+
351
+ async openNewFile() {
352
+ const existingNames = (this.browser.entries || [])
353
+ .map((e) => e?.name)
354
+ .filter(Boolean);
355
+ await fileEditorStore.openNewFile(this.browser.currentPath, existingNames, async () => {
356
+ // Callback on successful save to refresh file list
357
+ await this.fetchFiles(this.browser.currentPath);
358
+ });
359
+ },
360
+
361
// --- File actions --------------------------------------------------------
362
async deleteFile(file) {
363
try {
@@ -181,13 +369,14 @@ const model = {
369
currentPath: this.browser.currentPath,
370
}),
371
});
184
- if (resp.ok) {
372
+ const data = await resp.json().catch(() => ({}));
373
+ if (resp.ok && !data.error) {
374
this.browser.entries = this.browser.entries.filter(
375
(e) => e.path !== file.path
376
);
377
window.toastFrontendSuccess("File deleted successfully", "File Deleted");
378
} else {
190
- window.toastFrontendError(`Error deleting file: ${await resp.text()}`, "Delete Error");
379
+ window.toastFrontendError(data.error || "Error deleting file", "Delete Error");
380
}
381
} catch (e) {
382
window.toastFrontendError(
@@ -222,8 +411,8 @@ const model = {
411
method: "POST",
412
body: formData,
413
});
225
- if (resp.ok) {
226
- const data = await resp.json();
414
+ const data = await resp.json().catch(() => ({}));
415
+ if (resp.ok && !data.error) {
416
this.browser.entries = data.data.entries;
417
this.browser.currentPath = data.data.current_path;
418
this.browser.parentPath = data.data.parent_path;
@@ -234,7 +423,7 @@ const model = {
423
alert(`Some files failed to upload:\n${msg}`);
424
}
425
} else {
237
- alert(await resp.text());
426
+ alert(data.error || "Error uploading files");
427
}
428
} catch (e) {
429
window.toastFrontendError(
webui/components/modals/file-browser/file-browser.html
+103
-9
@@ -28,6 +28,16 @@
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>
40
+ </div>
41
42
<!-- Files list -->
43
<div class="files-list">
@@ -48,10 +58,60 @@
58
<div class="file-size" x-text="$store.fileBrowser.formatFileSize(file.size)"></div>
59
<div class="file-date" x-text="$store.fileBrowser.formatDate(file.modified)"></div>
60
<div class="file-actions">
51
- <button class="btn-icon-action" @click.stop="$store.fileBrowser.downloadFile(file)" title="Download file">
61
+ <!-- Single-item actions (Edit/Rename/...) are grouped under a dropdown -->
62
+ <div
63
+ class="dropdown file-actions-dropdown"
64
+ x-data="{
65
+ open: false,
66
+ toggle() { this.open = !this.open; },
67
+ close() { this.open = false; }
68
+ }"
69
+ @click.outside="close()"
70
+ @keydown.escape.window="close()"
71
+ >
72
+ <button
73
+ type="button"
74
+ class="btn-icon-action dropdown-trigger"
75
+ @click.stop="toggle()"
76
+ :aria-expanded="open.toString()"
77
+ aria-label="More actions"
78
+ title="More actions"
79
+ >
80
+ <span class="material-symbols-outlined">more_vert</span>
81
+ </button>
82
+
83
+ <div
84
+ class="dropdown-menu"
85
+ x-show="open"
86
+ x-transition
87
+ :class="{ 'bottom': $el.getBoundingClientRect().bottom > window.innerHeight - 100 }"
88
+ style="display: none;"
89
+ @click="close()"
90
+ >
91
+ <button
92
+ type="button"
93
+ class="dropdown-item"
94
+ x-show="!file.is_dir"
95
+ @click="$store.fileBrowser.openFileEditor(file)"
96
+ >
97
+ <span class="material-symbols-outlined">file_open</span>
98
+ <span>Edit</span>
99
+ </button>
100
+
101
+ <button
102
+ type="button"
103
+ class="dropdown-item"
104
+ @click="$store.fileBrowser.openRenameModal(file)"
105
+ >
106
+ <span class="material-symbols-outlined">drive_file_rename_outline</span>
107
+ <span>Rename</span>
108
+ </button>
109
+ </div>
110
+ </div>
111
+ <button class="btn-icon-action" x-show="!file.is_dir" @click.stop="$store.fileBrowser.downloadFile(file)" title="Download file">
112
<span class="material-symbols-outlined">download</span>
113
</button>
54
- <button class="btn-icon-action" @click.stop="$confirmClick($event, () => $store.fileBrowser.deleteFile(file))" title="Delete file">
114
+ <button class="btn-icon-action" @click.stop="$confirmClick($event, () => $store.fileBrowser.deleteFile(file))" title="Delete item">
115
<span class="material-symbols-outlined">delete</span>
116
</button>
117
</div>
@@ -132,8 +192,7 @@
192
193
/* File Browser Styles */
194
.files-list,
135
- .file-header,
136
- .file-item {
195
+ .file-header {
196
width: 100%;
197
border-radius: 4px;
198
overflow: hidden;
@@ -142,7 +201,7 @@
201
/* Header Styles */
202
.file-header {
203
display: grid;
145
- grid-template-columns: 2fr 0.6fr 1fr 80px;
204
+ grid-template-columns: 2fr 0.6fr 1fr 160px;
205
background: var(--secondary-bg);
206
padding: 8px 0;
207
font-weight: bold;
@@ -160,14 +219,15 @@
219
/* File Item Styles */
220
.file-item {
221
display: grid;
163
- grid-template-columns: 2fr 0.6fr 1fr 80px;
222
+ grid-template-columns: 2fr 0.6fr 1fr 160px;
223
align-items: center;
224
padding: 8px 0;
225
font-size: 0.875rem;
226
border-top: 1px solid var(--color-border);
227
transition: background-color 0.2s;
228
white-space: nowrap;
170
- overflow: hidden;
229
+ border-radius: 4px;
230
+ overflow: visible; /* allow action dropdown menus to overflow the row */
231
color: var(--color-text);
232
}
233
.file-item:hover {
@@ -221,6 +281,17 @@
281
border: 1px solid var(--color-border);
282
border-radius: 8px;
283
}
284
+
285
+ .new-item-buttons {
286
+ display: flex;
287
+ justify-content: flex-end;
288
+ gap: 0.5em;
289
+ margin-bottom: 0.5em;
290
+ }
291
+ .new-item-buttons button {
292
+ margin-left: 0.5em;
293
+ }
294
+
295
.nav-button {
296
padding: 4px 12px;
297
border: 1px solid var(--color-border);
@@ -285,11 +356,34 @@
356
display: flex;
357
gap: var(--spacing-xs);
358
}
359
+ .file-actions .dropdown-menu {
360
+ top: auto !important;
361
+ bottom: 100% !important;
362
+ margin-top: 0;
363
+ margin-bottom: var(--spacing-xs);
364
+ }
365
+ .btn-new-item {
366
+ display: inline-flex;
367
+ align-items: center;
368
+ gap: 0.4rem;
369
+ padding: 8px 14px;
370
+ }
371
+ .btn-secondary {
372
+ background: var(--color-secondary);
373
+ color: var(--color-text);
374
+ border: 1px solid var(--color-border);
375
+ }
376
+ .btn-secondary:hover {
377
+ filter: brightness(1.05);
378
+ }
379
+ .btn-secondary:active {
380
+ filter: brightness(0.95);
381
+ }
382
/* Responsive Design */
383
@media (max-width: 768px) {
384
.file-header,
385
.file-item {
292
- grid-template-columns: 1fr 0.5fr 80px;
386
+ grid-template-columns: 1fr 0.5fr 120px;
387
}
388
.file-cell-date,
389
.file-date {
@@ -299,7 +393,7 @@
393
@media (max-width: 540px) {
394
.file-header,
395
.file-item {
302
- grid-template-columns: 1fr 80px;
396
+ grid-template-columns: 1fr 120px;
397
}
398
.file-cell-size,
399
.file-size,
webui/components/modals/file-browser/rename-modal.html
new
+86
@@ -0,0 +1,86 @@
1
+<html>
2
+<head>
3
+ <title>Rename</title>
4
+ <script type="module">
5
+ import { store } from "/components/modals/file-browser/file-browser-store.js";
6
+ </script>
7
+</head>
8
+<body>
9
+ <div x-data>
10
+ <template x-if="$store.fileBrowser">
11
+ <div class="rename-modal-root" x-init="$nextTick(() => $refs.renameInput?.focus())">
12
+ <div class="rename-form">
13
+ <label class="rename-label" x-text="$store.fileBrowser.renameMode === 'create-folder' ? 'New folder name' : 'New name'"></label>
14
+ <input
15
+ class="rename-input"
16
+ type="text"
17
+ x-ref="renameInput"
18
+ x-model="$store.fileBrowser.renameName"
19
+ @keydown.enter.prevent="$store.fileBrowser.confirmRename()"
20
+ :disabled="$store.fileBrowser.isRenaming"
21
+ />
22
+ <template x-if="$store.fileBrowser.renameError">
23
+ <p class="rename-error" x-text="$store.fileBrowser.renameError"></p>
24
+ </template>
25
+ <p class="rename-hint" x-text="$store.fileBrowser.renameMode === 'create-folder' ? 'Folder will be created in the current directory.' : 'Renaming will keep the item in the same directory.'"></p>
26
+ </div>
27
+ </div>
28
+ </template>
29
+ </div>
30
+
31
+ <template x-if="$store.fileBrowser">
32
+ <div class="modal-footer" data-modal-footer>
33
+ <button class="btn btn-cancel" @click="$store.fileBrowser.closeRenameModal()" :disabled="$store.fileBrowser.isRenaming">Cancel</button>
34
+ <button class="btn btn-ok" @click="$store.fileBrowser.confirmRename()" :disabled="$store.fileBrowser.isRenaming || !$store.fileBrowser.renameName.trim()" x-text="$store.fileBrowser.renameMode === 'create-folder' ? 'Create Folder' : 'Rename'"></button>
35
+ </div>
36
+ </template>
37
+
38
+ <style>
39
+ .rename-modal-root {
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: var(--spacing-md);
43
+ width: 100%;
44
+ }
45
+
46
+ .rename-form {
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: var(--spacing-sm);
50
+ margin: 1rem;
51
+ }
52
+
53
+ .rename-label {
54
+ font-weight: 600;
55
+ color: var(--color-text);
56
+ }
57
+
58
+ .rename-input {
59
+ width: 100%;
60
+ padding: 10px 12px;
61
+ border-radius: 6px;
62
+ border: 1px solid var(--color-border);
63
+ background: var(--color-background);
64
+ color: var(--color-text);
65
+ }
66
+
67
+ .rename-input:disabled {
68
+ opacity: 0.7;
69
+ cursor: not-allowed;
70
+ }
71
+
72
+ .rename-hint {
73
+ margin: 0;
74
+ color: var(--color-text-secondary);
75
+ font-size: 0.85rem;
76
+ }
77
+
78
+ .rename-error {
79
+ margin: 0;
80
+ color: var(--color-accent);
81
+ font-weight: 500;
82
+ font-size: 0.85rem;
83
+ }
84
+ </style>
85
+</body>
86
+</html>