Make file browser paths editable
Some checks failed
Build And Publish Docker Images / plan (push) Has been cancelled
Build And Publish Docker Images / build (push) Has been cancelled

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.
This commit is contained in:
Alessandro 2026-06-04 17:29:53 +02:00
parent 8c61573019
commit f9d8167a00
6 changed files with 390 additions and 38 deletions

View file

@ -1,9 +1,11 @@
import { createStore } from "/js/AlpineStore.js";
import { fetchApi } from "/js/api.js";
import { callJsonApi, fetchApi } from "/js/api.js";
import { formatDateTime } from "/js/time-utils.js";
import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
import { openLatest as openLatestSurface } from "/js/surfaces.js";
const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"]);
const BROWSER_EXTENSIONS = new Set([
@ -60,6 +62,12 @@ const model = {
initialPath: "", // Store path for open() call
closePromise: null,
error: null,
pathInput: "",
pathError: "",
isPathSubmitting: false,
rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
settingsLoadPromise: null,
settingsUpdatedHandler: null,
renameTarget: null,
renameName: "",
renameMode: "rename",
@ -74,7 +82,14 @@ const model = {
// --- Lifecycle -----------------------------------------------------------
init() {
// Nothing special to do here; all methods available immediately
if (this.settingsUpdatedHandler) return;
this.settingsUpdatedHandler = (event) => {
const value = event?.detail?.file_browser_remember_last_directory;
if (typeof value !== "boolean") return;
this.rememberLastDirectory = value;
if (!value) this.clearRememberedDirectory();
};
document.addEventListener("settings-updated", this.settingsUpdatedHandler);
},
// --- Public API (called from button/link) --------------------------------
@ -85,6 +100,8 @@ const model = {
this.history = [];
this.searchQuery = "";
this.isBulkBusy = false;
this.pathError = "";
this.isPathSubmitting = false;
try {
// Open modal FIRST (immediate UI feedback)
@ -92,12 +109,22 @@ const model = {
"modals/file-browser/file-browser.html"
);
// Use stored initial path or default
path = path || this.initialPath || this.browser.currentPath || "$WORK_DIR";
await this.loadDirectoryPreference();
const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
path = explicitPath || rememberedPath || "$WORK_DIR";
this.browser.currentPath = path;
this.syncPathInput();
// Fetch files
await this.fetchFiles(this.browser.currentPath);
const loaded = await this.fetchFiles(this.browser.currentPath, {
preserveOnError: Boolean(rememberedPath && path === rememberedPath),
suppressErrorToast: Boolean(rememberedPath && path === rememberedPath),
});
if (!loaded && rememberedPath && path === rememberedPath) {
this.clearRememberedDirectory();
await this.fetchFiles("$WORK_DIR");
}
// await modal close
await this.closePromise;
@ -112,6 +139,7 @@ const model = {
handleClose() {
// Close the modal manually
this.disposeScopedTooltips();
window.closeModal();
},
@ -124,6 +152,9 @@ const model = {
this.openDropdownPath = null;
this.searchQuery = "";
this.isBulkBusy = false;
this.pathInput = "";
this.pathError = "";
this.isPathSubmitting = false;
this.resetRenameState();
},
@ -249,6 +280,85 @@ const model = {
});
},
normalizeOpeningPath(path) {
return String(path || "").trim();
},
normalizeSubmittedPath(path) {
const trimmed = String(path || "").trim();
if (!trimmed || trimmed === "$WORK_DIR") return trimmed;
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
},
syncPathInput() {
this.pathInput = this.browser.currentPath || "";
},
resetPathInput() {
this.syncPathInput();
this.pathError = "";
},
async loadDirectoryPreference() {
if (this.settingsLoadPromise) return await this.settingsLoadPromise;
this.settingsLoadPromise = (async () => {
try {
const response = await callJsonApi("settings_get", null);
const remember = response?.settings?.file_browser_remember_last_directory;
this.rememberLastDirectory =
typeof remember === "boolean" ? remember : DEFAULT_REMEMBER_LAST_DIRECTORY;
} catch (error) {
console.warn("Failed to load file browser directory preference:", error);
this.rememberLastDirectory = DEFAULT_REMEMBER_LAST_DIRECTORY;
} finally {
if (!this.rememberLastDirectory) this.clearRememberedDirectory();
this.settingsLoadPromise = null;
}
return this.rememberLastDirectory;
})();
return await this.settingsLoadPromise;
},
getRememberedDirectory() {
if (!this.rememberLastDirectory) return "";
try {
return localStorage.getItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY) || "";
} catch {
return "";
}
},
rememberCurrentDirectory(path = this.browser.currentPath) {
if (!this.rememberLastDirectory) return;
const directory = this.normalizeOpeningPath(path);
if (!directory || directory === "$WORK_DIR") return;
try {
localStorage.setItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY, directory);
} catch {}
},
clearRememberedDirectory() {
try {
localStorage.removeItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY);
} catch {}
},
disposeScopedTooltips() {
const root = document.querySelector(".file-browser-root");
const tooltipApi = globalThis.bootstrap?.Tooltip;
if (!root || !tooltipApi) return;
root.querySelectorAll("[data-bs-tooltip-initialized]").forEach((element) => {
const instance = tooltipApi.getInstance(element);
try {
instance?.dispose();
} catch {}
});
document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove());
},
// --- Modal helpers -------------------------------------------------------
normalizePath(path) {
if (!path) return "";
@ -380,7 +490,9 @@ const model = {
},
// --- Navigation ----------------------------------------------------------
async fetchFiles(path = "") {
async fetchFiles(path = "", options = {}) {
const preserveOnError = options?.preserveOnError === true;
const suppressErrorToast = options?.suppressErrorToast === true;
this.isLoading = true;
// Preserve scroll position if refreshing the same path
@ -397,14 +509,31 @@ const model = {
);
const data = await response.json().catch(() => ({}));
if (response.ok && !data.error) {
const result = data.data || {};
const requestedPath = String(path || "");
const resultError =
data.error ||
result.error ||
(
requestedPath &&
requestedPath !== "$WORK_DIR" &&
!result.current_path &&
!(result.entries || []).length
? "Directory not found or not accessible"
: ""
);
if (response.ok && !resultError) {
if (!isSamePath) this.searchQuery = "";
this.browser.entries = this.decorateEntries(
data.data.entries || [],
result.entries || [],
selectedPaths
);
this.browser.currentPath = data.data.current_path;
this.browser.parentPath = data.data.parent_path;
this.browser.currentPath = result.current_path;
this.browser.parentPath = result.parent_path;
this.syncPathInput();
this.pathError = "";
this.rememberCurrentDirectory(this.browser.currentPath);
// Set isLoading to false BEFORE restoring scroll to avoid reactivity issues
this.isLoading = false;
@ -413,20 +542,23 @@ const model = {
if (scrollPos) {
this.restoreScrollPosition(scrollPos);
}
return true;
} else {
const msg = data.error || "Error fetching files";
const msg = resultError || "Error fetching files";
console.error("Error fetching files:", msg);
this.browser.entries = [];
if (!preserveOnError) this.browser.entries = [];
this.isLoading = false;
window.toastFrontendError(msg, "File Browser Error");
if (!suppressErrorToast) window.toastFrontendError(msg, "File Browser Error");
return false;
}
} catch (e) {
window.toastFrontendError(
"Error fetching files: " + e.message,
"File Browser Error"
);
this.browser.entries = [];
const message = "Error fetching files: " + e.message;
if (!suppressErrorToast) {
window.toastFrontendError(message, "File Browser Error");
}
if (!preserveOnError) this.browser.entries = [];
this.isLoading = false;
return false;
}
},
@ -437,6 +569,38 @@ const model = {
await this.fetchFiles(path);
},
async submitPath() {
if (this.isPathSubmitting || this.isLoading) return;
const path = this.normalizeSubmittedPath(this.pathInput);
if (!path) {
this.pathError = "Enter a directory path.";
return;
}
this.isPathSubmitting = true;
this.pathError = "";
try {
const previousPath = this.browser.currentPath;
const loaded = await this.fetchFiles(path, {
preserveOnError: true,
suppressErrorToast: true,
});
if (loaded) {
if (previousPath && previousPath !== this.browser.currentPath) {
this.history.push(previousPath);
}
return;
}
this.pathError = "Directory not found or not accessible.";
} finally {
this.isPathSubmitting = false;
}
},
async navigateUp() {
if (this.browser.parentPath) {
this.history.push(this.browser.currentPath);
@ -855,6 +1019,7 @@ const model = {
}
}
this.disposeScopedTooltips();
await window.closeModal?.("modals/file-browser/file-browser.html");
} catch (error) {
window.toastFrontendError?.(

View file

@ -19,14 +19,41 @@
<!-- File Browser Content -->
<div x-show="!$store.fileBrowser.isLoading" class="file-browser-content">
<!-- Path navigator -->
<div class="path-navigator">
<button class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
<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" />
</svg>
Up
</button>
<div id="current-path"><span id="path-text" x-text="$store.fileBrowser.browser.currentPath"></span></div>
<div class="path-navigator-wrap">
<form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
<button type="button" class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
<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" />
</svg>
Up
</button>
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
<span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
<input
id="current-path"
class="path-input"
type="text"
x-model="$store.fileBrowser.pathInput"
@focus="$event.target.select()"
@keydown.escape.stop.prevent="$store.fileBrowser.resetPathInput()"
:aria-invalid="$store.fileBrowser.pathError ? 'true' : 'false'"
aria-label="Directory path"
spellcheck="false"
autocomplete="off"
/>
<button
type="submit"
class="btn-icon-action path-submit"
:disabled="$store.fileBrowser.isPathSubmitting || $store.fileBrowser.isLoading"
aria-label="Go to directory"
>
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</div>
</form>
<template x-if="$store.fileBrowser.pathError">
<div class="path-error" x-text="$store.fileBrowser.pathError"></div>
</template>
</div>
<div class="file-browser-toolbar">
<div class="file-search-shell">
@ -442,11 +469,17 @@
}
/* Path Navigator Styles */
.path-navigator-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.path-navigator {
overflow: hidden;
display: flex;
align-items: center;
gap: 24px;
gap: 0.75rem;
background-color: var(--color-message-bg);
padding: 0.5rem var(--spacing-sm);
margin: 0;
@ -454,6 +487,75 @@
border-radius: 8px;
}
.path-navigator .back-button {
flex: 0 0 auto;
}
.path-input-shell {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.path-input-icon {
position: absolute;
left: 0.75rem;
font-size: 1.15rem;
color: var(--color-primary);
opacity: 0.8;
pointer-events: none;
}
.path-input {
width: 100%;
height: 2.25rem;
min-width: 0;
border: 1px solid transparent;
border-radius: 6px;
background: color-mix(in srgb, var(--color-input) 78%, transparent);
color: var(--color-text);
padding: 0 2.5rem 0 2.35rem;
font: inherit;
font-family: 'Roboto Mono', monospace;
-webkit-font-optical-sizing: auto;
font-optical-sizing: auto;
line-height: 1;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.path-input:focus {
outline: none;
border-color: var(--color-primary);
background: var(--color-input-focus);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.path-input-shell.has-error .path-input {
border-color: var(--color-error);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-error) 18%, transparent);
}
.path-submit {
position: absolute;
right: 0.35rem;
width: 1.75rem;
height: 1.75rem;
color: var(--color-text);
}
.path-submit:disabled {
opacity: 0.5;
cursor: wait;
}
.path-error {
color: var(--color-error);
font-size: 0.8rem;
padding: 0 0.25rem;
}
.file-browser-toolbar {
display: flex;
align-items: center;
@ -549,16 +651,6 @@
.nav-button.back-button:hover {
background-color: var(--color-secondary-dark);
}
#current-path {
opacity: 0.9;
}
#path-text {
font-family: 'Roboto Mono', monospace;
-webkit-font-optical-sizing: auto;
font-optical-sizing: auto;
opacity: 0.9;
}
/* Folder Specific Styles */
.file-item[data-is-dir="true"] {
cursor: pointer;
@ -621,6 +713,14 @@
}
/* Responsive Design */
@media (max-width: 768px) {
.path-navigator {
align-items: stretch;
flex-direction: column;
gap: 0.5rem;
}
.path-navigator .back-button {
align-self: flex-start;
}
.file-browser-toolbar {
align-items: stretch;
flex-direction: column;

View file

@ -28,6 +28,21 @@
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Remember last file browser location</div>
<div class="field-description">
Open the file browser at the most recently visited directory when no specific path is requested.
</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="$store.settings.settings.file_browser_remember_last_directory" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Show workdir structure to the agent</div>