Make file browser paths editable

Add direct directory navigation to the file browser path bar and preserve the current listing when typed paths fail. Add a default-enabled setting to remember the last successful file browser directory, while keeping explicit open paths deterministic.

Alessandro committed Jun 4, 2026 at 17:29 UTC f9d8167a0004632ea7d8b37f585f392c39865919
6 files changed +390 -38
helpers/file_browser.py
+10 -1
@@ -313,6 +313,10 @@ class FileBrowser:
313 full_path = (self.base_dir / current_path).resolve()
314 if not str(full_path).startswith(str(self.base_dir)):
315 raise ValueError("Invalid path")
316 + if not full_path.exists():
317 + raise FileNotFoundError("Directory not found")
318 + if not full_path.is_dir():
319 + raise NotADirectoryError("Path is not a directory")
320
321 # Use ls command instead of os.scandir for better error handling
322 files, folders = self._get_files_via_ls(full_path)
@@ -342,7 +346,12 @@ class FileBrowser:
346
347 except Exception as e:
348 PrintStyle.error(f"Error reading directory: {e}")
345 - return {"entries": [], "current_path": "", "parent_path": ""}
349 + return {
350 + "entries": [],
351 + "current_path": current_path,
352 + "parent_path": "",
353 + "error": str(e),
354 + }
355
356 def get_full_path(self, file_path: str, allow_dir: bool = False) -> str:
357 """Get full file path if it exists and is within base_dir"""
helpers/settings.py
+5
@@ -66,6 +66,7 @@ class Settings(TypedDict):
66 workdir_max_folders: int
67 workdir_max_lines: int
68 workdir_gitignore: str
69 + file_browser_remember_last_directory: bool
70
71 api_keys: dict[str, str]
72
@@ -506,6 +507,10 @@ def get_default_settings() -> Settings:
507 workdir_max_folders=get_default_value("workdir_max_folders", 20),
508 workdir_max_lines=get_default_value("workdir_max_lines", 250),
509 workdir_gitignore=get_default_value("workdir_gitignore", gitignore),
510 + file_browser_remember_last_directory=get_default_value(
511 + "file_browser_remember_last_directory",
512 + True,
513 + ),
514 rfc_auto_docker=get_default_value("rfc_auto_docker", True),
515 rfc_url=get_default_value("rfc_url", "localhost"),
516 rfc_password="",
tests/test_file_browser_navigation.py new
+58
@@ -0,0 +1,58 @@
1 +from pathlib import Path
2 +import sys
3 +
4 +
5 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
6 +if str(PROJECT_ROOT) not in sys.path:
7 + sys.path.insert(0, str(PROJECT_ROOT))
8 +
9 +
10 +from helpers.file_browser import FileBrowser
11 +
12 +
13 +def read(*parts: str) -> str:
14 + return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
15 +
16 +
17 +def test_file_browser_remember_last_directory_defaults_enabled() -> None:
18 + settings_source = read("helpers", "settings.py")
19 +
20 + assert "file_browser_remember_last_directory: bool" in settings_source
21 + assert "file_browser_remember_last_directory=get_default_value(" in settings_source
22 + assert '"file_browser_remember_last_directory",\n True,' in settings_source
23 +
24 +
25 +def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> None:
26 + html = read("webui", "components", "modals", "file-browser", "file-browser.html")
27 + store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
28 + workdir_settings = read("webui", "components", "settings", "agent", "workdir.html")
29 +
30 + assert 'class="path-navigator"' in html
31 + assert 'x-model="$store.fileBrowser.pathInput"' in html
32 + assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html
33 + assert "Go to directory" in html
34 + assert "$store.fileBrowser.pathError" in html
35 +
36 + assert "FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY" in store
37 + assert 'callJsonApi("settings_get", null)' in store
38 + assert "file_browser_remember_last_directory" in store
39 + assert "getRememberedDirectory()" in store
40 + assert "rememberCurrentDirectory(this.browser.currentPath)" in store
41 + assert "clearRememberedDirectory()" in store
42 +
43 + explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath")
44 + remembered_path_index = store.index("const rememberedPath = !explicitPath")
45 + assert explicit_path_index < remembered_path_index
46 +
47 + assert "Remember last file browser location" in workdir_settings
48 + assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings
49 +
50 +
51 +def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
52 + missing_directory = tmp_path / "missing"
53 +
54 + result = FileBrowser().get_files(str(missing_directory))
55 +
56 + assert result["entries"] == []
57 + assert result["current_path"] == str(missing_directory)
58 + assert result["error"] == "Directory not found"
webui/components/modals/file-browser/file-browser-store.js
+183 -18
@@ -1,9 +1,11 @@
1 import { createStore } from "/js/AlpineStore.js";
2 -import { fetchApi } from "/js/api.js";
2 +import { callJsonApi, fetchApi } from "/js/api.js";
3 import { formatDateTime } from "/js/time-utils.js";
4 import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
5 import { openLatest as openLatestSurface } from "/js/surfaces.js";
6
7 +const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
8 +const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
9 const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
10 const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"]);
11 const BROWSER_EXTENSIONS = new Set([
@@ -60,6 +62,12 @@ const model = {
62 initialPath: "", // Store path for open() call
63 closePromise: null,
64 error: null,
65 + pathInput: "",
66 + pathError: "",
67 + isPathSubmitting: false,
68 + rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
69 + settingsLoadPromise: null,
70 + settingsUpdatedHandler: null,
71 renameTarget: null,
72 renameName: "",
73 renameMode: "rename",
@@ -74,7 +82,14 @@ const model = {
82
83 // --- Lifecycle -----------------------------------------------------------
84 init() {
77 - // Nothing special to do here; all methods available immediately
85 + if (this.settingsUpdatedHandler) return;
86 + this.settingsUpdatedHandler = (event) => {
87 + const value = event?.detail?.file_browser_remember_last_directory;
88 + if (typeof value !== "boolean") return;
89 + this.rememberLastDirectory = value;
90 + if (!value) this.clearRememberedDirectory();
91 + };
92 + document.addEventListener("settings-updated", this.settingsUpdatedHandler);
93 },
94
95 // --- Public API (called from button/link) --------------------------------
@@ -85,6 +100,8 @@ const model = {
100 this.history = [];
101 this.searchQuery = "";
102 this.isBulkBusy = false;
103 + this.pathError = "";
104 + this.isPathSubmitting = false;
105
106 try {
107 // Open modal FIRST (immediate UI feedback)
@@ -92,12 +109,22 @@ const model = {
109 "modals/file-browser/file-browser.html"
110 );
111
95 - // Use stored initial path or default
96 - path = path || this.initialPath || this.browser.currentPath || "$WORK_DIR";
112 + await this.loadDirectoryPreference();
113 + const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
114 + const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
115 + path = explicitPath || rememberedPath || "$WORK_DIR";
116 this.browser.currentPath = path;
117 + this.syncPathInput();
118
119 // Fetch files
100 - await this.fetchFiles(this.browser.currentPath);
120 + const loaded = await this.fetchFiles(this.browser.currentPath, {
121 + preserveOnError: Boolean(rememberedPath && path === rememberedPath),
122 + suppressErrorToast: Boolean(rememberedPath && path === rememberedPath),
123 + });
124 + if (!loaded && rememberedPath && path === rememberedPath) {
125 + this.clearRememberedDirectory();
126 + await this.fetchFiles("$WORK_DIR");
127 + }
128
129 // await modal close
130 await this.closePromise;
@@ -112,6 +139,7 @@ const model = {
139
140 handleClose() {
141 // Close the modal manually
142 + this.disposeScopedTooltips();
143 window.closeModal();
144 },
145
@@ -124,6 +152,9 @@ const model = {
152 this.openDropdownPath = null;
153 this.searchQuery = "";
154 this.isBulkBusy = false;
155 + this.pathInput = "";
156 + this.pathError = "";
157 + this.isPathSubmitting = false;
158 this.resetRenameState();
159 },
160
@@ -249,6 +280,85 @@ const model = {
280 });
281 },
282
283 + normalizeOpeningPath(path) {
284 + return String(path || "").trim();
285 + },
286 +
287 + normalizeSubmittedPath(path) {
288 + const trimmed = String(path || "").trim();
289 + if (!trimmed || trimmed === "$WORK_DIR") return trimmed;
290 + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
291 + },
292 +
293 + syncPathInput() {
294 + this.pathInput = this.browser.currentPath || "";
295 + },
296 +
297 + resetPathInput() {
298 + this.syncPathInput();
299 + this.pathError = "";
300 + },
301 +
302 + async loadDirectoryPreference() {
303 + if (this.settingsLoadPromise) return await this.settingsLoadPromise;
304 +
305 + this.settingsLoadPromise = (async () => {
306 + try {
307 + const response = await callJsonApi("settings_get", null);
308 + const remember = response?.settings?.file_browser_remember_last_directory;
309 + this.rememberLastDirectory =
310 + typeof remember === "boolean" ? remember : DEFAULT_REMEMBER_LAST_DIRECTORY;
311 + } catch (error) {
312 + console.warn("Failed to load file browser directory preference:", error);
313 + this.rememberLastDirectory = DEFAULT_REMEMBER_LAST_DIRECTORY;
314 + } finally {
315 + if (!this.rememberLastDirectory) this.clearRememberedDirectory();
316 + this.settingsLoadPromise = null;
317 + }
318 + return this.rememberLastDirectory;
319 + })();
320 +
321 + return await this.settingsLoadPromise;
322 + },
323 +
324 + getRememberedDirectory() {
325 + if (!this.rememberLastDirectory) return "";
326 + try {
327 + return localStorage.getItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY) || "";
328 + } catch {
329 + return "";
330 + }
331 + },
332 +
333 + rememberCurrentDirectory(path = this.browser.currentPath) {
334 + if (!this.rememberLastDirectory) return;
335 + const directory = this.normalizeOpeningPath(path);
336 + if (!directory || directory === "$WORK_DIR") return;
337 + try {
338 + localStorage.setItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY, directory);
339 + } catch {}
340 + },
341 +
342 + clearRememberedDirectory() {
343 + try {
344 + localStorage.removeItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY);
345 + } catch {}
346 + },
347 +
348 + disposeScopedTooltips() {
349 + const root = document.querySelector(".file-browser-root");
350 + const tooltipApi = globalThis.bootstrap?.Tooltip;
351 + if (!root || !tooltipApi) return;
352 +
353 + root.querySelectorAll("[data-bs-tooltip-initialized]").forEach((element) => {
354 + const instance = tooltipApi.getInstance(element);
355 + try {
356 + instance?.dispose();
357 + } catch {}
358 + });
359 + document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove());
360 + },
361 +
362 // --- Modal helpers -------------------------------------------------------
363 normalizePath(path) {
364 if (!path) return "";
@@ -380,7 +490,9 @@ const model = {
490 },
491
492 // --- Navigation ----------------------------------------------------------
383 - async fetchFiles(path = "") {
493 + async fetchFiles(path = "", options = {}) {
494 + const preserveOnError = options?.preserveOnError === true;
495 + const suppressErrorToast = options?.suppressErrorToast === true;
496 this.isLoading = true;
497
498 // Preserve scroll position if refreshing the same path
@@ -397,14 +509,31 @@ const model = {
509 );
510 const data = await response.json().catch(() => ({}));
511
400 - if (response.ok && !data.error) {
512 + const result = data.data || {};
513 + const requestedPath = String(path || "");
514 + const resultError =
515 + data.error ||
516 + result.error ||
517 + (
518 + requestedPath &&
519 + requestedPath !== "$WORK_DIR" &&
520 + !result.current_path &&
521 + !(result.entries || []).length
522 + ? "Directory not found or not accessible"
523 + : ""
524 + );
525 +
526 + if (response.ok && !resultError) {
527 if (!isSamePath) this.searchQuery = "";
528 this.browser.entries = this.decorateEntries(
403 - data.data.entries || [],
529 + result.entries || [],
530 selectedPaths
531 );
406 - this.browser.currentPath = data.data.current_path;
407 - this.browser.parentPath = data.data.parent_path;
532 + this.browser.currentPath = result.current_path;
533 + this.browser.parentPath = result.parent_path;
534 + this.syncPathInput();
535 + this.pathError = "";
536 + this.rememberCurrentDirectory(this.browser.currentPath);
537
538 // Set isLoading to false BEFORE restoring scroll to avoid reactivity issues
539 this.isLoading = false;
@@ -413,20 +542,23 @@ const model = {
542 if (scrollPos) {
543 this.restoreScrollPosition(scrollPos);
544 }
545 + return true;
546 } else {
417 - const msg = data.error || "Error fetching files";
547 + const msg = resultError || "Error fetching files";
548 console.error("Error fetching files:", msg);
419 - this.browser.entries = [];
549 + if (!preserveOnError) this.browser.entries = [];
550 this.isLoading = false;
421 - window.toastFrontendError(msg, "File Browser Error");
551 + if (!suppressErrorToast) window.toastFrontendError(msg, "File Browser Error");
552 + return false;
553 }
554 } catch (e) {
424 - window.toastFrontendError(
425 - "Error fetching files: " + e.message,
426 - "File Browser Error"
427 - );
428 - this.browser.entries = [];
555 + const message = "Error fetching files: " + e.message;
556 + if (!suppressErrorToast) {
557 + window.toastFrontendError(message, "File Browser Error");
558 + }
559 + if (!preserveOnError) this.browser.entries = [];
560 this.isLoading = false;
561 + return false;
562 }
563 },
564
@@ -437,6 +569,38 @@ const model = {
569 await this.fetchFiles(path);
570 },
571
572 + async submitPath() {
573 + if (this.isPathSubmitting || this.isLoading) return;
574 +
575 + const path = this.normalizeSubmittedPath(this.pathInput);
576 + if (!path) {
577 + this.pathError = "Enter a directory path.";
578 + return;
579 + }
580 +
581 + this.isPathSubmitting = true;
582 + this.pathError = "";
583 +
584 + try {
585 + const previousPath = this.browser.currentPath;
586 + const loaded = await this.fetchFiles(path, {
587 + preserveOnError: true,
588 + suppressErrorToast: true,
589 + });
590 +
591 + if (loaded) {
592 + if (previousPath && previousPath !== this.browser.currentPath) {
593 + this.history.push(previousPath);
594 + }
595 + return;
596 + }
597 +
598 + this.pathError = "Directory not found or not accessible.";
599 + } finally {
600 + this.isPathSubmitting = false;
601 + }
602 + },
603 +
604 async navigateUp() {
605 if (this.browser.parentPath) {
606 this.history.push(this.browser.currentPath);
@@ -855,6 +1019,7 @@ const model = {
1019 }
1020 }
1021
1022 + this.disposeScopedTooltips();
1023 await window.closeModal?.("modals/file-browser/file-browser.html");
1024 } catch (error) {
1025 window.toastFrontendError?.(
webui/components/modals/file-browser/file-browser.html
+119 -19
@@ -19,14 +19,41 @@
19 <!-- File Browser Content -->
20 <div x-show="!$store.fileBrowser.isLoading" class="file-browser-content">
21 <!-- Path navigator -->
22 - <div class="path-navigator">
23 - <button class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
24 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
25 - <path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
26 - </svg>
27 - Up
28 - </button>
29 - <div id="current-path"><span id="path-text" x-text="$store.fileBrowser.browser.currentPath"></span></div>
22 + <div class="path-navigator-wrap">
23 + <form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
24 + <button type="button" class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
25 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
26 + <path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
27 + </svg>
28 + Up
29 + </button>
30 + <div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
31 + <span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
32 + <input
33 + id="current-path"
34 + class="path-input"
35 + type="text"
36 + x-model="$store.fileBrowser.pathInput"
37 + @focus="$event.target.select()"
38 + @keydown.escape.stop.prevent="$store.fileBrowser.resetPathInput()"
39 + :aria-invalid="$store.fileBrowser.pathError ? 'true' : 'false'"
40 + aria-label="Directory path"
41 + spellcheck="false"
42 + autocomplete="off"
43 + />
44 + <button
45 + type="submit"
46 + class="btn-icon-action path-submit"
47 + :disabled="$store.fileBrowser.isPathSubmitting || $store.fileBrowser.isLoading"
48 + aria-label="Go to directory"
49 + >
50 + <span class="material-symbols-outlined">arrow_forward</span>
51 + </button>
52 + </div>
53 + </form>
54 + <template x-if="$store.fileBrowser.pathError">
55 + <div class="path-error" x-text="$store.fileBrowser.pathError"></div>
56 + </template>
57 </div>
58 <div class="file-browser-toolbar">
59 <div class="file-search-shell">
@@ -442,11 +469,17 @@
469 }
470
471 /* Path Navigator Styles */
472 + .path-navigator-wrap {
473 + display: flex;
474 + flex-direction: column;
475 + gap: 0.35rem;
476 + }
477 +
478 .path-navigator {
479 overflow: hidden;
480 display: flex;
481 align-items: center;
449 - gap: 24px;
482 + gap: 0.75rem;
483 background-color: var(--color-message-bg);
484 padding: 0.5rem var(--spacing-sm);
485 margin: 0;
@@ -454,6 +487,75 @@
487 border-radius: 8px;
488 }
489
490 + .path-navigator .back-button {
491 + flex: 0 0 auto;
492 + }
493 +
494 + .path-input-shell {
495 + position: relative;
496 + display: flex;
497 + align-items: center;
498 + flex: 1;
499 + min-width: 0;
500 + }
501 +
502 + .path-input-icon {
503 + position: absolute;
504 + left: 0.75rem;
505 + font-size: 1.15rem;
506 + color: var(--color-primary);
507 + opacity: 0.8;
508 + pointer-events: none;
509 + }
510 +
511 + .path-input {
512 + width: 100%;
513 + height: 2.25rem;
514 + min-width: 0;
515 + border: 1px solid transparent;
516 + border-radius: 6px;
517 + background: color-mix(in srgb, var(--color-input) 78%, transparent);
518 + color: var(--color-text);
519 + padding: 0 2.5rem 0 2.35rem;
520 + font: inherit;
521 + font-family: 'Roboto Mono', monospace;
522 + -webkit-font-optical-sizing: auto;
523 + font-optical-sizing: auto;
524 + line-height: 1;
525 + transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
526 + }
527 +
528 + .path-input:focus {
529 + outline: none;
530 + border-color: var(--color-primary);
531 + background: var(--color-input-focus);
532 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
533 + }
534 +
535 + .path-input-shell.has-error .path-input {
536 + border-color: var(--color-error);
537 + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-error) 18%, transparent);
538 + }
539 +
540 + .path-submit {
541 + position: absolute;
542 + right: 0.35rem;
543 + width: 1.75rem;
544 + height: 1.75rem;
545 + color: var(--color-text);
546 + }
547 +
548 + .path-submit:disabled {
549 + opacity: 0.5;
550 + cursor: wait;
551 + }
552 +
553 + .path-error {
554 + color: var(--color-error);
555 + font-size: 0.8rem;
556 + padding: 0 0.25rem;
557 + }
558 +
559 .file-browser-toolbar {
560 display: flex;
561 align-items: center;
@@ -549,16 +651,6 @@
651 .nav-button.back-button:hover {
652 background-color: var(--color-secondary-dark);
653 }
552 - #current-path {
553 - opacity: 0.9;
554 - }
555 - #path-text {
556 - font-family: 'Roboto Mono', monospace;
557 - -webkit-font-optical-sizing: auto;
558 - font-optical-sizing: auto;
559 - opacity: 0.9;
560 - }
561 -
654 /* Folder Specific Styles */
655 .file-item[data-is-dir="true"] {
656 cursor: pointer;
@@ -621,6 +713,14 @@
713 }
714 /* Responsive Design */
715 @media (max-width: 768px) {
716 + .path-navigator {
717 + align-items: stretch;
718 + flex-direction: column;
719 + gap: 0.5rem;
720 + }
721 + .path-navigator .back-button {
722 + align-self: flex-start;
723 + }
724 .file-browser-toolbar {
725 align-items: stretch;
726 flex-direction: column;
webui/components/settings/agent/workdir.html
+15
@@ -28,6 +28,21 @@
28 </div>
29 </div>
30
31 + <div class="field">
32 + <div class="field-label">
33 + <div class="field-title">Remember last file browser location</div>
34 + <div class="field-description">
35 + Open the file browser at the most recently visited directory when no specific path is requested.
36 + </div>
37 + </div>
38 + <div class="field-control">
39 + <label class="toggle">
40 + <input type="checkbox" x-model="$store.settings.settings.file_browser_remember_last_directory" />
41 + <span class="toggler"></span>
42 + </label>
43 + </div>
44 + </div>
45 +
46 <div class="field">
47 <div class="field-label">
48 <div class="field-title">Show workdir structure to the agent</div>