mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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.
This commit is contained in:
parent
51f3ed00a1
commit
ffddc3ebcb
20 changed files with 879 additions and 110 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
36
webui/components/modals/file-browser/AGENTS.md
Normal file
36
webui/components/modals/file-browser/AGENTS.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,30 @@
|
|||
<html>
|
||||
<html
|
||||
class="surface-modal file-browser-modal modal-no-backdrop"
|
||||
data-surface-id="files"
|
||||
data-surface-modal-path="modals/file-browser/file-browser.html"
|
||||
data-surface-dock-title="Open Files in canvas"
|
||||
data-surface-dock-icon="dock_to_right"
|
||||
data-canvas-surface="files"
|
||||
data-canvas-modal-path="modals/file-browser/file-browser.html"
|
||||
data-canvas-dock-title="Open Files in canvas"
|
||||
data-canvas-dock-icon="dock_to_right"
|
||||
>
|
||||
<head>
|
||||
<title>File Browser</title>
|
||||
<script type="module">
|
||||
import { store } from "/components/modals/file-browser/file-browser-store.js";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data>
|
||||
<body class="file-browser-modal-body">
|
||||
<div
|
||||
x-data
|
||||
class="file-browser-shell"
|
||||
:class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }"
|
||||
x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"
|
||||
x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"
|
||||
>
|
||||
<template x-if="$store.fileBrowser">
|
||||
<div class="file-browser-root">
|
||||
<div class="file-browser-root" :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="$store.fileBrowser.isLoading" class="loading-state">
|
||||
|
|
@ -21,11 +37,16 @@
|
|||
<!-- Path navigator -->
|
||||
<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
|
||||
type="button"
|
||||
class="nav-button back-button"
|
||||
@click="$store.fileBrowser.navigateUp()"
|
||||
:disabled="!$store.fileBrowser.browser.parentPath || $store.fileBrowser.isLoading"
|
||||
aria-label="Navigate up"
|
||||
title="Navigate up"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_upward</span>
|
||||
<span class="nav-button-label">Up</span>
|
||||
</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>
|
||||
|
|
@ -79,13 +100,23 @@
|
|||
</div>
|
||||
|
||||
<div class="new-item-buttons">
|
||||
<button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFile()">
|
||||
<span class="material-symbols-outlined">note_add</span>
|
||||
New File
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ok btn-new-item"
|
||||
@click="$store.fileBrowser.openNewFile()"
|
||||
aria-label="New file"
|
||||
title="New file"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">note_add</span>
|
||||
</button>
|
||||
<button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFolderModal()">
|
||||
<span class="material-symbols-outlined">create_new_folder</span>
|
||||
New Folder
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ok btn-new-item"
|
||||
@click="$store.fileBrowser.openNewFolderModal()"
|
||||
aria-label="New folder"
|
||||
title="New folder"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">create_new_folder</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -297,20 +328,81 @@
|
|||
|
||||
<!-- Modal Footer (outside template x-if so it exists immediately) -->
|
||||
<template x-if="$store.fileBrowser">
|
||||
<div class="modal-footer" data-modal-footer>
|
||||
<div class="modal-footer file-browser-footer" data-modal-footer :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
|
||||
<label class="btn btn-upload">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
|
||||
Upload Files
|
||||
<input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
|
||||
</label>
|
||||
<button class="btn btn-cancel" @click="$store.fileBrowser.handleClose()">Close Browser</button>
|
||||
<button class="btn btn-cancel" x-show="xAttrs($el)?.mode !== 'canvas'" @click="$store.fileBrowser.handleClose()">Close Browser</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-browser-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal {
|
||||
box-sizing: border-box;
|
||||
width: min(78vw, 1040px);
|
||||
height: min(82vh, 820px);
|
||||
min-width: min(420px, calc(100vw - 16px));
|
||||
min-height: min(360px, calc(100vh - 16px));
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal.is-focus-mode {
|
||||
resize: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal .modal-header {
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.75rem 0.35rem 1rem;
|
||||
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal .modal-scroll,
|
||||
.modal-inner.file-browser-modal .modal-bd.file-browser-modal-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal .modal-bd.file-browser-modal-body > div[x-data],
|
||||
.modal-inner.file-browser-modal .file-browser-shell {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-browser-shell.is-surface {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* File Browser Root */
|
||||
.file-browser-root {
|
||||
container: file-browser / inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
|
@ -318,6 +410,16 @@
|
|||
min-height: 400px;
|
||||
}
|
||||
|
||||
.file-browser-root.is-surface {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal .file-browser-root {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
|
|
@ -354,6 +456,17 @@
|
|||
flex-direction: column;
|
||||
padding: var(--spacing-sm) var(--spacing-sm);
|
||||
gap: var(--spacing-sm);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-browser-shell.is-surface .file-browser-content {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal .file-browser-content {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* File Browser Styles */
|
||||
|
|
@ -363,6 +476,34 @@
|
|||
/* Removed overflow: hidden to allow dropdown menus to be visible */
|
||||
}
|
||||
|
||||
.file-browser-shell.is-surface .files-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal .files-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.file-browser-shell.is-surface .file-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.modal-inner.file-browser-modal .file-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.file-header {
|
||||
width: 100%;
|
||||
|
|
@ -633,23 +774,51 @@
|
|||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 4px 12px;
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.22rem 0.62rem 0.24rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-input) 86%, var(--color-panel) 14%);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.nav-button:hover {
|
||||
background: var(--hover-bg);
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.nav-button.back-button {
|
||||
background-color: var(--color-secondary);
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
min-width: 3.1rem;
|
||||
}
|
||||
.nav-button .material-symbols-outlined {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.nav-button-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.nav-button:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-input-focus) 84%, var(--color-primary) 16%);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.nav-button.back-button:hover {
|
||||
background-color: var(--color-secondary-dark);
|
||||
.nav-button:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
}
|
||||
.nav-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* Folder Specific Styles */
|
||||
.file-item[data-is-dir="true"] {
|
||||
|
|
@ -681,6 +850,16 @@
|
|||
background-color: #2b309c;
|
||||
}
|
||||
|
||||
.file-browser-footer.is-surface {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-panel) 90%, var(--color-background) 10%);
|
||||
}
|
||||
|
||||
/* File Actions */
|
||||
.file-actions {
|
||||
display: flex;
|
||||
|
|
@ -697,8 +876,17 @@
|
|||
.btn-new-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 8px 14px;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
min-width: 2.8rem;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.btn-new-item .material-symbols-outlined {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--color-secondary);
|
||||
|
|
@ -729,13 +917,6 @@
|
|||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.new-item-buttons {
|
||||
width: 100%;
|
||||
}
|
||||
.new-item-buttons .btn-new-item {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.file-header {
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 7.5rem;
|
||||
}
|
||||
|
|
@ -750,15 +931,26 @@
|
|||
@media (max-width: 540px) {
|
||||
.file-header,
|
||||
.file-item {
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) 7.5rem;
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
|
||||
}
|
||||
.file-cell-size,
|
||||
.file-size,
|
||||
.file-cell-date,
|
||||
.file-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@container file-browser (max-width: 620px) {
|
||||
.file-header,
|
||||
.file-item {
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
|
||||
}
|
||||
.file-cell-date,
|
||||
.file-date {
|
||||
display: none;
|
||||
}
|
||||
.file-actions {
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
- Each CSS file owns a named surface or primitive family such as buttons, messages, modals, notifications, scheduler, settings, surfaces, tables, or toast.
|
||||
- Component-specific styles should usually stay inside the component HTML unless they are intentionally shared.
|
||||
- `modals.css` owns the shared stacked modal shell, backdrop, scroll area, footer slot, modal button classes, floating/no-backdrop modal behavior, and shared modal section primitives.
|
||||
- `surfaces.css` owns surface modal switchers, action rails, draggable header affordances, focus-button state, and right-canvas surface primitives.
|
||||
- `index.css` defines global theme variables such as `--color-*`, `--spacing-*`, `--font-size-*`, and `--transition-speed`.
|
||||
|
||||
## Local Contracts
|
||||
|
|
|
|||
|
|
@ -20,6 +20,22 @@
|
|||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-inner.surface-modal.is-draggable-surface-modal .modal-header {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.modal-inner.surface-modal.is-focus-mode {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.modal-inner.surface-modal.is-focus-mode .surface-modal-focus-button,
|
||||
.surface-modal-focus-button.is-active {
|
||||
color: var(--color-text);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 16%, var(--color-background));
|
||||
}
|
||||
|
||||
.surface-switcher,
|
||||
.modal-surface-switcher {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
- `extensions.js` owns frontend extension loading.
|
||||
- `components.js` owns `<x-component>` loading, component caching, module injection, nested component processing, and `globalThis.xAttrs`.
|
||||
- `modals.js` owns the stacked modal shell, `openModal`, `closeModal`, `scrollModal`, footer relocation, backdrop, and modal z-index behavior.
|
||||
- `surfaces.js` owns shared surface registration, right-canvas/modal mode routing, surface modal action rails, and reusable draggable/focus modal chrome.
|
||||
- `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
|
||||
- Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,29 @@ const urlHandlers = new Set();
|
|||
const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
|
||||
|
||||
export const CORE_SURFACES = [
|
||||
{
|
||||
id: "files",
|
||||
title: "Files",
|
||||
icon: "folder",
|
||||
order: 5,
|
||||
modalPath: "modals/file-browser/file-browser.html",
|
||||
async beginDockHandoff() {
|
||||
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||
store.beginSurfaceHandoff?.();
|
||||
},
|
||||
async finishDockHandoff(payload = {}) {
|
||||
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||
store.finishSurfaceHandoff?.(payload);
|
||||
},
|
||||
async cancelDockHandoff() {
|
||||
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||
store.cancelSurfaceHandoff?.();
|
||||
},
|
||||
async open(payload = {}) {
|
||||
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
|
||||
await store.openSurface(payload.path || payload.filePath || payload.directory || "");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
title: "Browser",
|
||||
|
|
@ -369,6 +392,202 @@ export function placeSurfaceModalHeaderAction(header, element, groupName = "wind
|
|||
refreshSurfaceModalActionRail(header);
|
||||
}
|
||||
|
||||
export function setupFloatingSurfaceModalChrome(options = {}) {
|
||||
const root = options.root || null;
|
||||
const modal = options.modal || root?.closest?.(".modal") || null;
|
||||
const inner = options.inner || modal?.querySelector?.(".modal-inner") || root?.closest?.(".modal-inner") || null;
|
||||
const header = options.header || inner?.querySelector?.(".modal-header") || null;
|
||||
if (!modal || !inner || !header) return () => {};
|
||||
|
||||
const viewportGap = Number.isFinite(Number(options.viewportGap)) ? Number(options.viewportGap) : 8;
|
||||
const minWidth = Number.isFinite(Number(options.minWidth)) ? Number(options.minWidth) : 320;
|
||||
const minHeight = Number.isFinite(Number(options.minHeight)) ? Number(options.minHeight) : 300;
|
||||
const modalClass = String(options.modalClass || "").trim();
|
||||
const focusButtonClass = String(options.focusButtonClass || "").trim();
|
||||
const focusEnabled = options.focus !== false;
|
||||
const focusLabel = options.focusLabel || "Focus mode";
|
||||
const restoreLabel = options.restoreLabel || "Restore size";
|
||||
const onBoundsChange = typeof options.onBoundsChange === "function" ? options.onBoundsChange : null;
|
||||
const onFocusChange = typeof options.onFocusChange === "function" ? options.onFocusChange : null;
|
||||
|
||||
modal.classList.add("surface-floating", "modal-floating");
|
||||
inner.classList.add("surface-modal", "is-draggable-surface-modal");
|
||||
if (modalClass) inner.classList.add(modalClass);
|
||||
|
||||
const viewportWidth = () => Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
|
||||
const viewportHeight = () => Math.max(document.documentElement.clientHeight || 0, globalThis.innerHeight || 0);
|
||||
const currentBounds = () => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
return {
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
};
|
||||
const normalizedBounds = (bounds = {}) => {
|
||||
const maxWidth = Math.max(minWidth, viewportWidth() - viewportGap * 2);
|
||||
const maxHeight = Math.max(minHeight, viewportHeight() - viewportGap * 2);
|
||||
const width = Math.min(Math.max(minWidth, Number(bounds.width || minWidth)), maxWidth);
|
||||
const height = Math.min(Math.max(minHeight, Number(bounds.height || minHeight)), maxHeight);
|
||||
return {
|
||||
left: Math.min(
|
||||
Math.max(viewportGap, Number(bounds.left || viewportGap)),
|
||||
Math.max(viewportGap, viewportWidth() - width - viewportGap),
|
||||
),
|
||||
top: Math.min(
|
||||
Math.max(viewportGap, Number(bounds.top || viewportGap)),
|
||||
Math.max(viewportGap, viewportHeight() - height - viewportGap),
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
const notifyBoundsChange = () => {
|
||||
try {
|
||||
onBoundsChange?.({
|
||||
...currentBounds(),
|
||||
focus: inner.classList.contains("is-focus-mode"),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Surface modal bounds callback failed", error);
|
||||
}
|
||||
};
|
||||
const setBounds = (bounds = {}) => {
|
||||
const next = normalizedBounds(bounds);
|
||||
inner.style.position = "fixed";
|
||||
inner.style.transform = "none";
|
||||
inner.style.left = `${Math.round(next.left)}px`;
|
||||
inner.style.top = `${Math.round(next.top)}px`;
|
||||
inner.style.width = `${Math.round(next.width)}px`;
|
||||
inner.style.height = `${Math.round(next.height)}px`;
|
||||
inner.style.maxWidth = `${Math.max(minWidth, viewportWidth() - viewportGap * 2)}px`;
|
||||
inner.style.maxHeight = `${Math.max(minHeight, viewportHeight() - viewportGap * 2)}px`;
|
||||
notifyBoundsChange();
|
||||
return next;
|
||||
};
|
||||
const focusBounds = () => ({
|
||||
left: viewportGap,
|
||||
top: viewportGap,
|
||||
width: viewportWidth() - viewportGap * 2,
|
||||
height: viewportHeight() - viewportGap * 2,
|
||||
});
|
||||
const clampGeometry = () => {
|
||||
if (inner.classList.contains("is-focus-mode")) {
|
||||
setBounds(focusBounds());
|
||||
return;
|
||||
}
|
||||
setBounds(currentBounds());
|
||||
};
|
||||
|
||||
const initialBounds = currentBounds();
|
||||
inner.style.left = `${Math.max(viewportGap, initialBounds.left)}px`;
|
||||
inner.style.top = `${Math.max(viewportGap, initialBounds.top)}px`;
|
||||
inner.style.transform = "none";
|
||||
clampGeometry();
|
||||
|
||||
let drag = null;
|
||||
let resizeObserver = null;
|
||||
let beforeFocusBounds = null;
|
||||
let focusButton = null;
|
||||
|
||||
const updateFocusButton = (active) => {
|
||||
if (!focusButton) return;
|
||||
const label = active ? restoreLabel : focusLabel;
|
||||
focusButton.setAttribute("aria-label", label);
|
||||
focusButton.setAttribute("title", label);
|
||||
focusButton.classList.toggle("is-active", active);
|
||||
const icon = focusButton.querySelector(".material-symbols-outlined");
|
||||
if (icon) icon.textContent = active ? "fullscreen_exit" : "fullscreen";
|
||||
};
|
||||
const setFocusMode = (enabled) => {
|
||||
const active = Boolean(enabled);
|
||||
if (active === inner.classList.contains("is-focus-mode")) return;
|
||||
if (active) {
|
||||
beforeFocusBounds = currentBounds();
|
||||
inner.classList.add("is-focus-mode");
|
||||
setBounds(focusBounds());
|
||||
} else {
|
||||
inner.classList.remove("is-focus-mode");
|
||||
setBounds(beforeFocusBounds || currentBounds());
|
||||
beforeFocusBounds = null;
|
||||
}
|
||||
updateFocusButton(active);
|
||||
try {
|
||||
onFocusChange?.(active);
|
||||
} catch (error) {
|
||||
console.error("Surface modal focus callback failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (!drag) return;
|
||||
setBounds({
|
||||
...currentBounds(),
|
||||
left: drag.left + event.clientX - drag.x,
|
||||
top: drag.top + event.clientY - drag.y,
|
||||
});
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
drag = null;
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
try {
|
||||
header.releasePointerCapture?.(header.__surfaceModalPointerId || 0);
|
||||
} catch {}
|
||||
};
|
||||
const onPointerDown = (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target?.closest?.("button, input, select, textarea, a, [data-no-modal-drag], .surface-modal-actions")) return;
|
||||
if (inner.classList.contains("is-focus-mode")) return;
|
||||
const bounds = currentBounds();
|
||||
drag = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
};
|
||||
header.__surfaceModalPointerId = event.pointerId;
|
||||
header.setPointerCapture?.(event.pointerId);
|
||||
globalThis.addEventListener("pointermove", onPointerMove);
|
||||
globalThis.addEventListener("pointerup", onPointerUp);
|
||||
event.preventDefault();
|
||||
};
|
||||
header.addEventListener("pointerdown", onPointerDown);
|
||||
|
||||
if (focusEnabled) {
|
||||
focusButton = globalThis.document.createElement("button");
|
||||
focusButton.type = "button";
|
||||
focusButton.className = ["surface-button", "surface-modal-focus-button", focusButtonClass]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
|
||||
const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
|
||||
updateFocusButton(false);
|
||||
focusButton.addEventListener("click", onFocusClick);
|
||||
focusButton.__surfaceModalFocusCleanup = () => focusButton.removeEventListener("click", onFocusClick);
|
||||
placeSurfaceModalHeaderAction(header, focusButton, "window");
|
||||
}
|
||||
|
||||
globalThis.addEventListener("resize", clampGeometry);
|
||||
if (globalThis.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(clampGeometry);
|
||||
resizeObserver.observe(inner);
|
||||
}
|
||||
|
||||
return () => {
|
||||
focusButton?.__surfaceModalFocusCleanup?.();
|
||||
focusButton?.remove();
|
||||
refreshSurfaceModalActionRail(header);
|
||||
header.removeEventListener("pointerdown", onPointerDown);
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
globalThis.removeEventListener("resize", clampGeometry);
|
||||
resizeObserver?.disconnect?.();
|
||||
inner.classList.remove("is-focus-mode", "is-draggable-surface-modal");
|
||||
};
|
||||
}
|
||||
|
||||
function markSurfaceModal(modal, metadata) {
|
||||
const element = modal?.element;
|
||||
const inner = modal?.inner || element?.querySelector?.(".modal-inner");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue