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>