From ffddc3ebcbedaa3f430d2c1ef8c669a8e144d3ad Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:19:58 +0200 Subject: [PATCH] Promote Files into shared canvas surface Add Files to the universal canvas rail and sidebar flow, with a reusable floating surface modal chrome that matches Browser/Desktop behavior. Make the File Browser modal draggable/resizable with Focus mode, keep Editor on the same draggable modal helper, and preserve dock/undock handoff state. Harden File Browser startup so empty paths resolve to the default workdir, restyle the Up control, compact New file/New folder actions, and hide Modified before Name/Size in narrow containers. Update DOX contracts and focused regression coverage for the new Files surface, modal chrome, default-path fallback, and compact layout behavior. --- api/get_work_dir_files.py | 2 +- api/get_work_dir_files.py.dox.md | 1 + extensions/webui/AGENTS.md | 1 + .../webui/right-canvas-panels/AGENTS.md | 28 ++ .../right-canvas-panels/files-panel.html | 11 + .../right_canvas_register_surfaces/AGENTS.md | 2 +- .../register-files.js | 44 +++ plugins/_editor/AGENTS.md | 1 + plugins/_editor/webui/editor-store.js | 46 ++- tests/test_file_browser_navigation.py | 78 +++++ tests/test_office_canvas_setup.py | 30 ++ webui/components/chat/input/input-store.js | 9 +- webui/components/modals/AGENTS.md | 6 +- .../components/modals/file-browser/AGENTS.md | 36 +++ .../modals/file-browser/file-browser-store.js | 189 +++++++++--- .../modals/file-browser/file-browser.html | 268 +++++++++++++++--- webui/css/AGENTS.md | 1 + webui/css/surfaces.css | 16 ++ webui/js/AGENTS.md | 1 + webui/js/surfaces.js | 219 ++++++++++++++ 20 files changed, 879 insertions(+), 110 deletions(-) create mode 100644 extensions/webui/right-canvas-panels/AGENTS.md create mode 100644 extensions/webui/right-canvas-panels/files-panel.html create mode 100644 extensions/webui/right_canvas_register_surfaces/register-files.js create mode 100644 webui/components/modals/file-browser/AGENTS.md diff --git a/api/get_work_dir_files.py b/api/get_work_dir_files.py index 7e8e99c7f..68aee4990 100644 --- a/api/get_work_dir_files.py +++ b/api/get_work_dir_files.py @@ -9,7 +9,7 @@ class GetWorkDirFiles(ApiHandler): return ["GET"] async def process(self, input: dict, request: Request) -> dict | Response: - current_path = request.args.get("path", "") + current_path = request.args.get("path", "") or "$WORK_DIR" if current_path == "$WORK_DIR": # if runtime.is_development(): # current_path = "work_dir" diff --git a/api/get_work_dir_files.py.dox.md b/api/get_work_dir_files.py.dox.md index 3d3048650..f5ff3e005 100644 --- a/api/get_work_dir_files.py.dox.md +++ b/api/get_work_dir_files.py.dox.md @@ -29,6 +29,7 @@ ## Key Concepts - Important called helpers/classes observed in the source: `FileBrowser`, `browser.get_files`, `runtime.call_development_function`. +- Empty `path` requests and explicit `$WORK_DIR` requests resolve to the default workdir path before `FileBrowser` is called, so the WebUI never receives an empty startup path for the default file browser view. - Keep request/response, tool, or helper semantics documented here at the same time as source changes. ## Work Guidance diff --git a/extensions/webui/AGENTS.md b/extensions/webui/AGENTS.md index d2421a144..f2c530b90 100644 --- a/extensions/webui/AGENTS.md +++ b/extensions/webui/AGENTS.md @@ -41,6 +41,7 @@ Direct child DOX files: | [initFw_end/AGENTS.md](initFw_end/AGENTS.md) | Post-WebUI-framework-initialization extensions. | | [json_api_call_after/AGENTS.md](json_api_call_after/AGENTS.md) | Frontend hooks after `callJsonApi()` calls. | | [json_api_call_before/AGENTS.md](json_api_call_before/AGENTS.md) | Frontend hooks before `callJsonApi()` calls. | +| [right-canvas-panels/AGENTS.md](right-canvas-panels/AGENTS.md) | Built-in right-canvas panel HTML contributions. | | [right_canvas_register_surfaces/AGENTS.md](right_canvas_register_surfaces/AGENTS.md) | Built-in right-canvas surface registrations. | | [set_messages_after_loop/AGENTS.md](set_messages_after_loop/AGENTS.md) | Frontend hooks after message DOM updates. | | [set_messages_before_loop/AGENTS.md](set_messages_before_loop/AGENTS.md) | Frontend hooks before message DOM updates. | diff --git a/extensions/webui/right-canvas-panels/AGENTS.md b/extensions/webui/right-canvas-panels/AGENTS.md new file mode 100644 index 000000000..014adda27 --- /dev/null +++ b/extensions/webui/right-canvas-panels/AGENTS.md @@ -0,0 +1,28 @@ +# Right Canvas Panel Extensions DOX + +## Purpose + +- Own built-in HTML panel contributions for the right-canvas surface area. + +## Ownership + +- `.html` files mount WebUI components into the `right-canvas-panels` extension point. +- Panel wrappers own `data-surface-id` anchors and active/mounted visibility bindings. + +## Local Contracts + +- Each panel must correspond to a registered right-canvas surface ID. +- Use `` for reusable component content instead of duplicating panel implementations. +- Keep canvas panels compatible with `.right-canvas-surface-panel` layout semantics. + +## Work Guidance + +- Prefer thin wrappers that delegate lifecycle and state to the owning component store. + +## Verification + +- Smoke-test opening the matching surface from the right-canvas rail. + +## Child DOX Index + +No child DOX files. diff --git a/extensions/webui/right-canvas-panels/files-panel.html b/extensions/webui/right-canvas-panels/files-panel.html new file mode 100644 index 000000000..fc0e7a1e6 --- /dev/null +++ b/extensions/webui/right-canvas-panels/files-panel.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/extensions/webui/right_canvas_register_surfaces/AGENTS.md b/extensions/webui/right_canvas_register_surfaces/AGENTS.md index d62eb20da..1387cda74 100644 --- a/extensions/webui/right_canvas_register_surfaces/AGENTS.md +++ b/extensions/webui/right_canvas_register_surfaces/AGENTS.md @@ -6,7 +6,7 @@ ## Ownership -- JavaScript files own registration of remote link, space agent, and future core canvas surfaces. +- JavaScript files own registration of remote link, space agent, file browser, and future core canvas surfaces. ## Local Contracts diff --git a/extensions/webui/right_canvas_register_surfaces/register-files.js b/extensions/webui/right_canvas_register_surfaces/register-files.js new file mode 100644 index 000000000..f1cd6bd40 --- /dev/null +++ b/extensions/webui/right_canvas_register_surfaces/register-files.js @@ -0,0 +1,44 @@ +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; + +function waitForElement(selector, timeoutMs = 3000) { + const found = document.querySelector(selector); + if (found) return Promise.resolve(found); + return new Promise((resolve) => { + const timeout = globalThis.setTimeout(() => { + observer.disconnect(); + resolve(document.querySelector(selector)); + }, timeoutMs); + const observer = new MutationObserver(() => { + const element = document.querySelector(selector); + if (!element) return; + globalThis.clearTimeout(timeout); + observer.disconnect(); + resolve(element); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); +} + +export default async function registerFilesSurface(surfaces) { + surfaces.registerSurface({ + id: "files", + title: "Files", + icon: "folder", + order: 5, + modalPath: "modals/file-browser/file-browser.html", + beginDockHandoff() { + fileBrowserStore.beginSurfaceHandoff?.(); + }, + finishDockHandoff(payload = {}) { + fileBrowserStore.finishSurfaceHandoff?.(payload); + }, + cancelDockHandoff() { + fileBrowserStore.cancelSurfaceHandoff?.(); + }, + async open(payload = {}) { + const panel = await waitForElement('[data-surface-id="files"] .file-browser-root'); + if (!panel) throw new Error("Files surface panel did not mount."); + await fileBrowserStore.openSurface(payload.path || payload.filePath || payload.directory || ""); + }, + }); +} diff --git a/plugins/_editor/AGENTS.md b/plugins/_editor/AGENTS.md index 92969d629..19d823537 100644 --- a/plugins/_editor/AGENTS.md +++ b/plugins/_editor/AGENTS.md @@ -16,6 +16,7 @@ - Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior. - Do not expose unsaved content or local paths beyond intended chat/context surfaces. +- Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work. ## Work Guidance diff --git a/plugins/_editor/webui/editor-store.js b/plugins/_editor/webui/editor-store.js index c4293320a..c62195113 100644 --- a/plugins/_editor/webui/editor-store.js +++ b/plugins/_editor/webui/editor-store.js @@ -2,7 +2,10 @@ import { createStore } from "/js/AlpineStore.js"; import { callJsonApi } from "/js/api.js"; import { getNamespacedClient } from "/js/websocket.js"; import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; -import { placeSurfaceModalHeaderAction } from "/js/surfaces.js"; +import { + placeSurfaceModalHeaderAction, + setupFloatingSurfaceModalChrome, +} from "/js/surfaces.js"; import { buildMarkdownPages, isExternalHref, @@ -1658,38 +1661,21 @@ const model = { if (!inner || !header || inner.dataset.editorModalReady === "1") return; inner.dataset.editorModalReady = "1"; inner.classList.add("editor-modal"); - const cleanup = []; - const focusButton = document.createElement("button"); - focusButton.type = "button"; - focusButton.className = "surface-button editor-modal-focus-button"; - focusButton.innerHTML = ''; - const updateFocusButton = (active) => { - const label = active ? "Restore size" : "Focus mode"; - focusButton.setAttribute("aria-label", label); - focusButton.setAttribute("title", label); - focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen"; - }; - updateFocusButton(false); - const onFocusClick = () => { - const active = !inner.classList.contains("is-focus-mode"); - inner.classList.toggle("is-focus-mode", active); - updateFocusButton(active); - }; - focusButton.addEventListener("click", onFocusClick); - placeSurfaceModalHeaderAction(header, focusButton, "window"); - cleanup.push(() => focusButton.removeEventListener("click", onFocusClick)); - cleanup.push(() => focusButton.remove()); - - this._headerCleanup = () => { - cleanup.splice(0).reverse().forEach((entry) => entry()); - delete inner.dataset.editorModalReady; - inner.classList.remove("editor-modal", "is-focus-mode"); - }; + const floatingCleanup = setupFloatingSurfaceModalChrome({ + root, + modalClass: "editor-modal", + focusButtonClass: "editor-modal-focus-button", + minWidth: 640, + minHeight: 460, + onBoundsChange: () => this.refreshSourceEditorLayout(), + onFocusChange: () => this.refreshSourceEditorLayout(), + }); const menuCleanup = this.installHeaderNewMenu(header); - const previousCleanup = this._headerCleanup; this._headerCleanup = () => { menuCleanup?.(); - previousCleanup?.(); + floatingCleanup?.(); + delete inner.dataset.editorModalReady; + inner.classList.remove("editor-modal", "is-focus-mode"); }; }, }; diff --git a/tests/test_file_browser_navigation.py b/tests/test_file_browser_navigation.py index 62e231359..7b6b1a723 100644 --- a/tests/test_file_browser_navigation.py +++ b/tests/test_file_browser_navigation.py @@ -28,6 +28,13 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N workdir_settings = read("webui", "components", "settings", "agent", "workdir.html") assert 'class="path-navigator"' in html + assert 'class="nav-button back-button"' in html + assert 'class="text-button back-button"' not in html + assert ".nav-button:focus-visible" in html + assert ".nav-button .material-symbols-outlined" in html + assert 'class="nav-button-label">Up' in html + assert "flex-direction: column;" in html + assert ".nav-button-label" in html assert 'x-model="$store.fileBrowser.pathInput"' in html assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html assert "Go to directory" in html @@ -39,6 +46,12 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N assert "getRememberedDirectory()" in store assert "rememberCurrentDirectory(this.browser.currentPath)" in store assert "clearRememberedDirectory()" in store + assert "scheduleMountedDefaultLoad()" in store + assert 'this.browser.currentPath = "";' in store + assert 'this.browser.parentPath = "";' in store + assert 'const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";' in store + assert "`/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`" in store + assert 'result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath)' in store explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath") remembered_path_index = store.index("const rememberedPath = !explicitPath") @@ -48,6 +61,71 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings +def test_file_browser_compact_controls_and_narrow_layout_contract() -> None: + html = read("webui", "components", "modals", "file-browser", "file-browser.html") + dox = read("webui", "components", "modals", "file-browser", "AGENTS.md") + + assert 'aria-label="New file"' in html + assert 'title="New file"' in html + assert 'aria-label="New folder"' in html + assert 'title="New folder"' in html + assert ">New File<" not in html + assert ">New Folder<" not in html + assert ".btn-new-item" in html + assert "width: 2.8rem;" in html + assert "height: 2.8rem;" in html + + assert "container: file-browser / inline-size;" in html + assert "@container file-browser (max-width: 620px)" in html + assert "grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;" in html + assert ".file-cell-date,\n .file-date {\n display: none;" in html + assert ".file-cell-size,\n .file-size" not in html + + assert "hiding the Modified date column" in dox + assert "New file and New folder controls icon-only" in dox + + +def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None: + api_source = read("api", "get_work_dir_files.py") + api_dox = read("api", "get_work_dir_files.py.dox.md") + + assert 'current_path = request.args.get("path", "") or "$WORK_DIR"' in api_source + assert 'current_path = "/a0"' in api_source + assert "Empty `path` requests and explicit `$WORK_DIR` requests resolve" in api_dox + + +def test_file_browser_is_registered_as_right_canvas_surface() -> None: + html = read("webui", "components", "modals", "file-browser", "file-browser.html") + store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") + surfaces = read("webui", "js", "surfaces.js") + register = read("extensions", "webui", "right_canvas_register_surfaces", "register-files.js") + panel = read("extensions", "webui", "right-canvas-panels", "files-panel.html") + input_store = read("webui", "components", "chat", "input", "input-store.js") + + assert 'id: "files"' in surfaces + assert 'title: "Files"' in surfaces + assert 'modalPath: "modals/file-browser/file-browser.html"' in surfaces + assert 'await store.openSurface(payload.path || payload.filePath || payload.directory || "")' in surfaces + assert 'data-surface-id="files"' in html + assert 'data-surface-modal-path="modals/file-browser/file-browser.html"' in html + assert 'class="surface-modal file-browser-modal modal-no-backdrop"' in html + assert 'class="file-browser-modal-body"' in html + assert 'x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"' in html + assert 'x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"' in html + assert ".modal-inner.file-browser-modal" in html + assert "resize: both" in html + assert "openSurface(path" in store + assert "setupFloatingSurfaceModalChrome" in store + assert 'focusButtonClass: "file-browser-modal-focus-button"' in store + assert "beginSurfaceHandoff()" in store + assert "finishSurfaceHandoff()" in store + assert 'id: "files"' in register + assert "fileBrowserStore.openSurface" in register + assert 'data-surface-id="files"' in panel + assert 'path="modals/file-browser/file-browser.html" mode="canvas"' in panel + assert 'openLatestSurface("files"' in input_store + + def test_file_browser_reports_missing_directory(tmp_path: Path) -> None: missing_directory = tmp_path / "missing" diff --git a/tests/test_office_canvas_setup.py b/tests/test_office_canvas_setup.py index 7ac4545b5..515b340d9 100644 --- a/tests/test_office_canvas_setup.py +++ b/tests/test_office_canvas_setup.py @@ -201,6 +201,36 @@ def test_browser_surface_restores_focus_mode_chrome(): assert ".modal-inner.browser-modal.is-focus-mode" in browser_panel +def test_files_and_editor_surface_modals_have_draggable_focus_chrome(): + surfaces_js = read("webui", "js", "surfaces.js") + surfaces_css = read("webui", "css", "surfaces.css") + file_store = read("webui", "components", "modals", "file-browser", "file-browser-store.js") + file_modal = read("webui", "components", "modals", "file-browser", "file-browser.html") + editor_store = read("plugins", "_editor", "webui", "editor-store.js") + editor_panel = read("plugins", "_editor", "webui", "editor-panel.html") + + assert "setupFloatingSurfaceModalChrome" in surfaces_js + assert "is-draggable-surface-modal" in surfaces_js + assert "surface-modal-focus-button" in surfaces_js + assert "fullscreen_exit" in surfaces_js + assert "Focus mode" in surfaces_js + assert "Restore size" in surfaces_js + assert ".modal-inner.surface-modal.is-draggable-surface-modal .modal-header" in surfaces_css + assert "cursor: move" in surfaces_css + assert ".surface-modal-focus-button.is-active" in surfaces_css + + assert "setupFloatingSurfaceModalChrome" in file_store + assert 'focusButtonClass: "file-browser-modal-focus-button"' in file_store + assert ".modal-inner.file-browser-modal" in file_modal + assert "resize: both" in file_modal + + assert "setupFloatingSurfaceModalChrome" in editor_store + assert 'focusButtonClass: "editor-modal-focus-button"' in editor_store + assert "onBoundsChange: () => this.refreshSourceEditorLayout()" in editor_store + assert "editor.resize?.(true)" in editor_store + assert ".modal-inner.editor-modal.is-focus-mode" in editor_panel + + def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop_runtime_code(): office_store = read("plugins", "_office", "webui", "office-store.js") office_panel = read("plugins", "_office", "webui", "office-panel.html") diff --git a/webui/components/chat/input/input-store.js b/webui/components/chat/input/input-store.js index 0ace742bf..2b58732c0 100644 --- a/webui/components/chat/input/input-store.js +++ b/webui/components/chat/input/input-store.js @@ -1,6 +1,7 @@ import { createStore } from "/js/AlpineStore.js"; import * as shortcuts from "/js/shortcuts.js"; import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; +import { openLatest as openLatestSurface } from "/js/surfaces.js"; import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js"; import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js"; import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; @@ -330,7 +331,13 @@ const model = { } } } - await fileBrowserStore.open(path); + let opened = false; + try { + opened = await openLatestSurface("files", { path, source: "sidebar" }); + } catch (error) { + console.error("Error opening Files surface", error); + } + if (!opened) await fileBrowserStore.open(path); }, focus() { diff --git a/webui/components/modals/AGENTS.md b/webui/components/modals/AGENTS.md index 45783107e..c1070ee91 100644 --- a/webui/components/modals/AGENTS.md +++ b/webui/components/modals/AGENTS.md @@ -27,4 +27,8 @@ ## Child DOX Index -No child DOX files. +Direct child DOX files: + +| Child | Scope | +| --- | --- | +| [file-browser/AGENTS.md](file-browser/AGENTS.md) | File browser modal and right-canvas Files surface workflow. | diff --git a/webui/components/modals/file-browser/AGENTS.md b/webui/components/modals/file-browser/AGENTS.md new file mode 100644 index 000000000..212288525 --- /dev/null +++ b/webui/components/modals/file-browser/AGENTS.md @@ -0,0 +1,36 @@ +# File Browser Modal DOX + +## Purpose + +- Own the WebUI file browser workflow for modal and right-canvas Files surface entry points. + +## Ownership + +- `file-browser.html` owns file list markup, path/search controls, scoped styles, and modal/canvas footer behavior. +- `file-browser-store.js` owns directory loading, remembered-location state, selection, upload/download/delete actions, and surface handoff state. +- `rename-modal.html` owns rename and create-folder prompts that reuse the file-browser store. + +## Local Contracts + +- Keep `open(path)` as the modal entry point for workflows that await browser close. +- Keep `openSurface(path)` as the right-canvas entry point; it must load files without opening or awaiting a modal. +- The floating file-browser modal must use the shared surface modal chrome so it remains draggable/resizable and exposes Focus mode. +- Preserve remembered-directory behavior: explicit paths win, then remembered path, then `$WORK_DIR`. +- Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list. +- Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns. +- Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels. +- Preserve surface actions that route supported files to Browser, Desktop, or Editor. + +## Work Guidance + +- Share markup and store behavior between modal and canvas modes; branch only on explicit component `mode`. +- Keep modal footer relocation compatible with `data-modal-footer` while allowing canvas mode to render inline controls. + +## Verification + +- Smoke-test opening Files as a modal and from the right-canvas rail. +- Run targeted file-browser tests after behavior changes. + +## Child DOX Index + +No child DOX files. diff --git a/webui/components/modals/file-browser/file-browser-store.js b/webui/components/modals/file-browser/file-browser-store.js index 20a32fedb..d142bfba5 100644 --- a/webui/components/modals/file-browser/file-browser-store.js +++ b/webui/components/modals/file-browser/file-browser-store.js @@ -2,8 +2,12 @@ import { createStore } from "/js/AlpineStore.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"; +import { + openLatest as openLatestSurface, + setupFloatingSurfaceModalChrome, +} from "/js/surfaces.js"; +const FILE_BROWSER_MODAL_PATH = "modals/file-browser/file-browser.html"; const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory"; const DEFAULT_REMEMBER_LAST_DIRECTORY = true; const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]); @@ -61,6 +65,8 @@ const model = { history: [], // navigation stack initialPath: "", // Store path for open() call closePromise: null, + isSurfaceHandoff: false, + surfaceHandoffPath: "", error: null, pathInput: "", pathError: "", @@ -68,6 +74,8 @@ const model = { rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY, settingsLoadPromise: null, settingsUpdatedHandler: null, + _floatingCleanup: null, + _mountedDefaultLoadTimer: null, renameTarget: null, renameName: "", renameMode: "rename", @@ -92,43 +100,36 @@ const model = { document.addEventListener("settings-updated", this.settingsUpdatedHandler); }, + onMount(element = null, options = {}) { + this._floatingCleanup?.(); + this._floatingCleanup = null; + const mode = options?.mode === "canvas" ? "canvas" : "modal"; + if (mode === "modal") { + this.setupFloatingModal(element); + } else { + this.scheduleMountedDefaultLoad(); + } + }, + + onUnmount() { + this._floatingCleanup?.(); + this._floatingCleanup = null; + this.cancelMountedDefaultLoad(); + }, + // --- Public API (called from button/link) -------------------------------- async open(path = "") { if (this.isLoading) return; // Prevent double-open - this.isLoading = true; - this.error = null; - this.history = []; - this.searchQuery = ""; - this.isBulkBusy = false; - this.pathError = ""; - this.isPathSubmitting = false; + this.resetOpenState(); try { // Open modal FIRST (immediate UI feedback) - this.closePromise = window.openModal( - "modals/file-browser/file-browser.html" - ); - - 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 - 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"); - } + this.closePromise = window.openModal(FILE_BROWSER_MODAL_PATH); + await this.loadOpeningPath(path); // await modal close await this.closePromise; - this.destroy(); + if (!this.isSurfaceHandoff) this.destroy(); } catch (error) { console.error("File browser error:", error); @@ -137,17 +138,45 @@ const model = { } }, + async openSurface(path = "") { + if (this.isLoading) return false; + this.resetOpenState(); + + try { + const retainedPath = this.normalizeOpeningPath( + path + || this.surfaceHandoffPath + || this.browser.currentPath + || this.initialPath + ); + return await this.loadOpeningPath(retainedPath); + } catch (error) { + console.error("File browser surface error:", error); + this.error = error?.message || "Failed to load files"; + this.isLoading = false; + return false; + } + }, + handleClose() { // Close the modal manually this.disposeScopedTooltips(); - window.closeModal(); + window.closeModal(FILE_BROWSER_MODAL_PATH); }, destroy() { + this._floatingCleanup?.(); + this._floatingCleanup = null; + this.cancelMountedDefaultLoad(); // Reset state when modal closes this.isLoading = false; this.history = []; this.initialPath = ""; + this.closePromise = null; + this.isSurfaceHandoff = false; + this.surfaceHandoffPath = ""; + this.browser.currentPath = ""; + this.browser.parentPath = ""; this.browser.entries = []; this.openDropdownPath = null; this.searchQuery = ""; @@ -158,7 +187,87 @@ const model = { this.resetRenameState(); }, + setupFloatingModal(element = null) { + this._floatingCleanup?.(); + this._floatingCleanup = setupFloatingSurfaceModalChrome({ + root: element, + modalClass: "file-browser-modal", + focusButtonClass: "file-browser-modal-focus-button", + minWidth: 420, + minHeight: 360, + }); + }, + + cancelMountedDefaultLoad() { + if (!this._mountedDefaultLoadTimer) return; + globalThis.clearTimeout(this._mountedDefaultLoadTimer); + this._mountedDefaultLoadTimer = null; + }, + + scheduleMountedDefaultLoad() { + this.cancelMountedDefaultLoad(); + this._mountedDefaultLoadTimer = globalThis.setTimeout(async () => { + this._mountedDefaultLoadTimer = null; + if (this.isLoading) return; + const targetPath = this.browser.currentPath || ""; + if (targetPath && this.browser.entries.length) { + this.syncPathInput(); + return; + } + try { + await this.loadOpeningPath(targetPath); + } catch (error) { + console.error("File browser default path load failed:", error); + } + }, 120); + }, + // --- Helpers ------------------------------------------------------------- + resetOpenState() { + this.cancelMountedDefaultLoad(); + this.isLoading = true; + this.error = null; + this.history = []; + this.searchQuery = ""; + this.isBulkBusy = false; + this.pathError = ""; + this.isPathSubmitting = false; + }, + + async loadOpeningPath(path = "") { + await this.loadDirectoryPreference(); + const explicitPath = this.normalizeOpeningPath(path || this.initialPath); + const rememberedPath = !explicitPath ? this.getRememberedDirectory() : ""; + const targetPath = explicitPath || rememberedPath || "$WORK_DIR"; + this.browser.currentPath = targetPath; + this.syncPathInput(); + + const loaded = await this.fetchFiles(this.browser.currentPath, { + preserveOnError: Boolean(rememberedPath && targetPath === rememberedPath), + suppressErrorToast: Boolean(rememberedPath && targetPath === rememberedPath), + }); + if (!loaded && rememberedPath && targetPath === rememberedPath) { + this.clearRememberedDirectory(); + return await this.fetchFiles("$WORK_DIR"); + } + return loaded; + }, + + beginSurfaceHandoff() { + this.isSurfaceHandoff = true; + this.surfaceHandoffPath = this.browser.currentPath || this.pathInput || ""; + }, + + finishSurfaceHandoff() { + this.isSurfaceHandoff = false; + this.surfaceHandoffPath = ""; + }, + + cancelSurfaceHandoff() { + this.isSurfaceHandoff = false; + this.surfaceHandoffPath = ""; + }, + isArchive(filename) { const archiveExts = ["zip", "tar", "gz", "rar", "7z"]; const ext = filename.split(".").pop().toLowerCase(); @@ -493,11 +602,13 @@ const model = { async fetchFiles(path = "", options = {}) { const preserveOnError = options?.preserveOnError === true; const suppressErrorToast = options?.suppressErrorToast === true; + const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR"; this.isLoading = true; // Preserve scroll position if refreshing the same path - const isSamePath = this.browser.currentPath === path || - (!path && !this.browser.currentPath); + const isSamePath = + this.browser.currentPath === requestedPath || + (requestedPath === "$WORK_DIR" && ["/a0", "$WORK_DIR", ""].includes(this.browser.currentPath)); const scrollPos = isSamePath ? this.saveScrollPosition() : null; const selectedPaths = isSamePath ? new Set(this.selectedFiles.map((file) => file.path)) @@ -505,12 +616,14 @@ const model = { try { const response = await fetchApi( - `/get_work_dir_files?path=${encodeURIComponent(path)}` + `/get_work_dir_files?path=${encodeURIComponent(requestedPath)}` ); const data = await response.json().catch(() => ({})); const result = data.data || {}; - const requestedPath = String(path || ""); + const entries = result.entries || []; + const resolvedCurrentPath = + result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath); const resultError = data.error || result.error || @@ -518,7 +631,7 @@ const model = { requestedPath && requestedPath !== "$WORK_DIR" && !result.current_path && - !(result.entries || []).length + !entries.length ? "Directory not found or not accessible" : "" ); @@ -526,10 +639,10 @@ const model = { if (response.ok && !resultError) { if (!isSamePath) this.searchQuery = ""; this.browser.entries = this.decorateEntries( - result.entries || [], + entries, selectedPaths ); - this.browser.currentPath = result.current_path; + this.browser.currentPath = resolvedCurrentPath; this.browser.parentPath = result.parent_path; this.syncPathInput(); this.pathError = ""; @@ -1020,7 +1133,7 @@ const model = { } this.disposeScopedTooltips(); - await window.closeModal?.("modals/file-browser/file-browser.html"); + await window.closeModal?.(FILE_BROWSER_MODAL_PATH); } catch (error) { window.toastFrontendError?.( error?.message || "Could not open file", diff --git a/webui/components/modals/file-browser/file-browser.html b/webui/components/modals/file-browser/file-browser.html index cca3f1c1c..3cb2b0cb2 100644 --- a/webui/components/modals/file-browser/file-browser.html +++ b/webui/components/modals/file-browser/file-browser.html @@ -1,14 +1,30 @@ - + File Browser - -
+ +