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>