Fix file browser action menu clipping

Render file action menus outside the scroll container with fixed positioning so dropdowns remain visible while file list scrolling stays intact.

Deduplicate component body assets during x-component loading and add regressions for the menu, opaque header, and duplicate scoped styles.
This commit is contained in:
Alessandro 2026-07-01 16:25:19 +02:00
parent 0f7429daef
commit 8ecd7a7f52
7 changed files with 136 additions and 63 deletions

View file

@ -123,11 +123,32 @@ def test_file_browser_editor_picker_modes_have_primary_footer_actions() -> None:
assert "Open in Editor action visible outside the overflow menu" in dox
editor_button_index = html.index("file-editor-open-action")
dropdown_menu_index = html.index('class="dropdown-menu"')
dropdown_menu_index = html.index('class="dropdown-menu file-actions-menu"')
assert editor_button_index < dropdown_menu_index
assert 'x-show="$store.fileBrowser.canOpenInActionMenu(file)"' in html
def test_file_browser_dropdown_escapes_scroll_container_and_header_is_opaque() -> None:
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
assert '<div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">' in html
assert 'overflow: auto;' in html
assert 'x-teleport="body"' in html
assert 'class="dropdown-menu file-actions-menu"' in html
assert ':style="$store.fileBrowser.dropdownStyle"' in html
assert '@click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"' in html
assert "getDropdownStyle(triggerElement)" in store
assert 'position: "fixed"' in store
assert 'zIndex: "6000"' in store
assert "var(--secondary-bg)" not in html
assert "var(--border-color)" not in html
assert "var(--text-secondary)" not in html
assert "background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);" in html
assert "border-bottom: 1px solid var(--color-border);" in html
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")

View file

@ -0,0 +1,17 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def read(*parts: str) -> str:
return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
def test_component_loader_deduplicates_body_assets() -> None:
source = read("webui", "js", "components.js")
assert 'const componentAssetSelector = "style, script, link[rel=\'stylesheet\']";' in source
assert "...doc.querySelectorAll(componentAssetSelector)" in source
assert "...Array.from(doc.body.childNodes).filter(" in source
assert "(node) => !node.matches?.(componentAssetSelector)" in source

View file

@ -19,6 +19,7 @@
- Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
- Preserve picker modes for Editor Open and Save As: Editor Open selects one or more Markdown or plain text files with a pinned primary action, and Save As selects the current folder plus a `.md` or `.txt` file name.
- Keep the row-level Open in Editor action visible outside the overflow menu for Editor-owned `.md` and `.txt` files.
- Keep row action menus visible without disabling file-list scrolling; menus may float outside the scroll container but must still close on outside click, Escape, action click, and list scroll.
- 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.
- Keep narrow mobile controls compact: Up shares the path row, and New file/New folder share the search row.

View file

@ -88,6 +88,7 @@ const model = {
renamePerformAction: null,
renameValidateName: null,
openDropdownPath: null, // Track which dropdown is currently open
dropdownStyle: {},
searchQuery: "",
isBulkBusy: false,
pickerMode: PICKER_MODE_NONE,
@ -783,9 +784,14 @@ const model = {
},
// --- Dropdown Management -------------------------------------------------
toggleDropdown(filePath) {
toggleDropdown(filePath, triggerElement = null) {
// Toggle: if already open, close it; otherwise open this one (closing any other)
this.openDropdownPath = this.openDropdownPath === filePath ? null : filePath;
if (this.openDropdownPath === filePath) {
this.closeDropdown();
return;
}
this.openDropdownPath = filePath;
this.dropdownStyle = this.getDropdownStyle(triggerElement);
},
isDropdownOpen(filePath) {
@ -794,6 +800,33 @@ const model = {
closeDropdown() {
this.openDropdownPath = null;
this.dropdownStyle = {};
},
getDropdownStyle(triggerElement) {
if (!triggerElement) return {};
const rect = triggerElement.getBoundingClientRect();
const gap = 6;
const padding = 8;
const minWidth = 180;
const spaceBelow = window.innerHeight - rect.bottom - gap - padding;
const spaceAbove = rect.top - gap - padding;
const openUp = spaceBelow < 160 && spaceAbove > spaceBelow;
const maxHeight = Math.max(96, openUp ? spaceAbove : spaceBelow);
const maxLeft = Math.max(padding, window.innerWidth - minWidth - padding);
const left = Math.min(Math.max(rect.right - minWidth, padding), maxLeft);
return {
position: "fixed",
left: `${Math.round(left)}px`,
right: "auto",
top: openUp ? "auto" : `${Math.round(rect.bottom + gap)}px`,
bottom: openUp ? `${Math.round(window.innerHeight - rect.top + gap)}px` : "auto",
minWidth: `${minWidth}px`,
maxHeight: `${Math.round(maxHeight)}px`,
zIndex: "6000",
};
},
// --- Navigation ----------------------------------------------------------

View file

@ -194,7 +194,7 @@
</div>
<!-- Files list -->
<div class="files-list">
<div class="files-list" @scroll="$store.fileBrowser.closeDropdown()">
<div class="file-header">
<div class="file-cell-select">
<input
@ -252,7 +252,7 @@
<button
type="button"
class="btn-icon-action dropdown-trigger"
@click.stop="$store.fileBrowser.toggleDropdown(file.path)"
@click.stop="$store.fileBrowser.toggleDropdown(file.path, $event.currentTarget)"
:aria-expanded="$store.fileBrowser.isDropdownOpen(file.path).toString()"
aria-label="More actions"
title="More actions"
@ -260,54 +260,56 @@
<span class="material-symbols-outlined">more_vert</span>
</button>
<div
class="dropdown-menu"
x-show="$store.fileBrowser.isDropdownOpen(file.path)"
x-transition
:class="{ 'bottom': $el.getBoundingClientRect().bottom > window.innerHeight - 100 }"
style="display: none;"
@click="$store.fileBrowser.closeDropdown()"
>
<button
type="button"
class="dropdown-item"
x-show="$store.fileBrowser.canOpenInActionMenu(file)"
@click="$store.fileBrowser.openInSurface(file)"
:title="$store.fileBrowser.surfaceActionTitle(file)"
<template x-teleport="body">
<div
class="dropdown-menu file-actions-menu"
x-show="$store.fileBrowser.isDropdownOpen(file.path)"
x-transition
:style="$store.fileBrowser.dropdownStyle"
style="display: none;"
@click.stop="$store.fileBrowser.closeDropdown()"
>
<span class="material-symbols-outlined" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
<span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
</button>
<button
type="button"
class="dropdown-item"
x-show="$store.fileBrowser.canOpenInActionMenu(file)"
@click="$store.fileBrowser.openInSurface(file)"
:title="$store.fileBrowser.surfaceActionTitle(file)"
>
<span class="material-symbols-outlined" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
<span x-text="$store.fileBrowser.surfaceActionLabel(file)"></span>
</button>
<button
type="button"
class="dropdown-item"
x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
@click="$store.fileBrowser.openFileEditor(file)"
>
<span class="material-symbols-outlined">file_open</span>
<span>Edit</span>
</button>
<button
type="button"
class="dropdown-item"
x-show="!file.is_dir && file.size <= 1048576 && !$store.fileBrowser.canOpenInSurface(file)"
@click="$store.fileBrowser.openFileEditor(file)"
>
<span class="material-symbols-outlined">file_open</span>
<span>Edit</span>
</button>
<button
type="button"
class="dropdown-item"
x-show="file.is_dir"
@click="$store.fileBrowser.downloadFile(file)"
>
<span class="material-symbols-outlined">folder_zip</span>
<span>Download ZIP</span>
</button>
<button
type="button"
class="dropdown-item"
x-show="file.is_dir"
@click="$store.fileBrowser.downloadFile(file)"
>
<span class="material-symbols-outlined">folder_zip</span>
<span>Download ZIP</span>
</button>
<button
type="button"
class="dropdown-item"
@click="$store.fileBrowser.openRenameModal(file)"
>
<span class="material-symbols-outlined">drive_file_rename_outline</span>
<span>Rename</span>
</button>
</div>
<button
type="button"
class="dropdown-item"
@click="$store.fileBrowser.openRenameModal(file)"
>
<span class="material-symbols-outlined">drive_file_rename_outline</span>
<span>Rename</span>
</button>
</div>
</template>
</div>
<button class="btn-icon-action" x-show="!file.is_dir" @click.stop="$store.fileBrowser.downloadFile(file)" title="Download file">
<span class="material-symbols-outlined">download</span>
@ -520,7 +522,6 @@
.files-list {
width: 100%;
border-radius: 4px;
/* Removed overflow: hidden to allow dropdown menus to be visible */
}
.file-browser-shell.is-surface .files-list {
@ -558,10 +559,10 @@
overflow: hidden;
display: grid;
grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 8.75rem;
background: var(--secondary-bg);
background: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);
padding: 8px 0;
font-weight: bold;
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid var(--color-border);
color: var(--color-primary);
}
.file-cell-select,
@ -637,13 +638,13 @@
}
.file-size,
.file-date {
color: var(--text-secondary);
color: var(--color-text-secondary);
}
/* No Files Message */
.no-files {
padding: 32px;
text-align: center;
color: var(--text-secondary);
color: var(--color-text-secondary);
}
.no-files .btn-clear-search {
display: inline-flex;
@ -974,11 +975,8 @@
border-color: color-mix(in srgb, var(--color-primary) 52%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.file-actions .dropdown-menu {
top: auto !important;
bottom: 100% !important;
margin-top: 0;
margin-bottom: var(--spacing-xs);
.file-actions-menu {
margin: 0;
}
.btn-new-item {
display: inline-flex;

View file

@ -36,6 +36,7 @@
- `scrollModal(id)` scrolls inside the top modal's `.modal-scroll`.
- Keep extension loader cache keys and extension point names stable for plugins.
- HTML extension loading turns discovered HTML files into `<x-component>` tags; JavaScript extensions must export a default function.
- `<x-component>` loading must process component `style`, `script`, and stylesheet-link assets only once, even when a component keeps its scoped `<style>` inside `<body>`.
- Frontend extension hooks such as `confirm_dialog_after_render` and `get_tool_message_handler` must preserve their mutable context contracts.
- Sanitize or safely render user/model-provided HTML and markdown.
- Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.

View file

@ -50,10 +50,12 @@ export async function importComponent(path, targetElement) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const componentAssetSelector = "style, script, link[rel='stylesheet']";
const allNodes = [
...doc.querySelectorAll("style"),
...doc.querySelectorAll("script"),
...doc.body.childNodes,
...doc.querySelectorAll(componentAssetSelector),
...Array.from(doc.body.childNodes).filter(
(node) => !node.matches?.(componentAssetSelector)
),
];
const loadPromises = [];
@ -264,4 +266,4 @@ const observer = new MutationObserver((mutations) => {
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
observer.observe(document.body, { childList: true, subtree: true });