import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t, I18nMixin } from '../lib/i18n.js'; import './shared/file-explorer.js'; /// `#files` — the whole of the caller's space, in one place. /// /// Two levels. The first is the **virtual root** (`GET /api/files/roots`): a /// synthetic list of everywhere this person can go — their home, the two memory /// stores, the shared folders and projects they belong to, and the two /// read-only trees. It is synthetic because those places are not subdirectories /// of one another: the explorer reads host-side while `shared/`, `projects/`, /// `skills/` and `docs/` are bind mounts inside the container, so a page /// anchored at `~` would show less than the user has, with no way to reach the /// rest (blueprint `dir-explorer.md`). The second level is the ordinary /// ``, which needs nothing new to browse any of them. /// /// The URL carries the **agent path of the open folder** — one `path` /// parameter, the same vocabulary the assistant uses, so a link is both /// shareable and something you can paste into a conversation. Which root it /// belongs to is derived from the roots list rather than stored beside it: two /// values that can disagree are two chances to be wrong, and the split is /// recoverable at any time (see [`_resolve`]). export class FilesPage extends I18nMixin(LightElement) { static properties = { _open: { state: true }, _roots: { state: true }, // null while loading _root: { state: true }, // the open root (an FsRoot), null = the root list _rel: { state: true }, // folder within that root ('' = the root itself) _error: { state: true }, }; constructor() { super(); this._open = false; this._roots = null; this._root = null; this._rel = ''; this._error = null; } connectedCallback() { super.connectedCallback(); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'files'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._sync(); }); // Back/forward, and the sidebar entry re-pushing a bare `#files`. window.addEventListener('hashchange', () => { if (this._open && location.hash.slice(1).startsWith('files')) this._sync(); }); } // ── Routing ─────────────────────────────────────────────────────────────── /// The open folder, as it appears in the hash: `#files?path=shared/casa/foto`. _pathFromHash() { const q = location.hash.indexOf('?'); if (q < 0) return ''; return new URLSearchParams(location.hash.slice(q + 1)).get('path') ?? ''; } /// Point the page at whatever the hash says. The roots are fetched once and /// kept: they change only with a membership, which remounts the container and /// is therefore already a page reload away. async _sync() { if (!this._roots) await this._loadRoots(); const path = this._pathFromHash(); if (!path) { this._root = null; this._rel = ''; this._error = null; return; } const hit = this._resolve(path); if (!hit) { // A path outside every root — a hand-edited URL, or a container-only path // (`/tmp/…`), which this page does not serve yet. Fall back to the list // rather than to an empty explorer that cannot explain itself. this._root = null; this._rel = ''; this._error = t('files.error.unknown_path', { path }); return; } this._root = hit.root; this._rel = hit.rel; this._error = null; } /// Split an agent path into the root it belongs to and the tail below it. /// Longest match wins, so a future nested root cannot be shadowed by the one /// above it. _resolve(path) { const roots = (this._roots ?? []) .filter(r => path === r.path || path.startsWith(`${r.path}/`)) .sort((a, b) => b.path.length - a.path.length); const root = roots[0]; return root ? { root, rel: path.slice(root.path.length).replace(/^\//, '') } : null; } async _loadRoots() { try { const res = await fetch('/api/files/roots'); if (!res.ok) throw new Error(await res.text()); this._roots = await res.json(); } catch (e) { this._roots = []; this._error = e.message; } } _go(path) { history.pushState({ page: 'files' }, '', path ? `#files?path=${encodeURIComponent(path)}` : '#files'); this._sync(); } // ── Root vocabulary ─────────────────────────────────────────────────────── static ICONS = { 'home': 'house-door', 'user-memory': 'journal-bookmark', 'shared-memory': 'journals', 'shared': 'folder-symlink', 'project': 'kanban', 'skills': 'mortarboard', 'docs': 'book', }; /// What a root is called. The server sends the discriminant, never a label: /// the words are UI copy and have to be translated. A root there can be /// several of names itself (a shared folder, a project); the rest is named /// after its kind. _labelFor(root) { return root.name ?? t(`files.root.${root.kind.replace('-', '_')}`); } // ── Rendering ───────────────────────────────────────────────────────────── _renderRootRow(root) { return html` `; } _renderRootList() { if (this._roots === null) { return html`
${t('common.loading')}
`; } return html`
${t('files.note.roots')}
${this._roots.map(r => this._renderRootRow(r))}
`; } /// One root, open. The header is the way back to the list — the explorer's /// own breadcrumb is rooted at this root and knows nothing above it. _renderExplorer() { return html`
${this._labelFor(this._root)} ${this._root.can_write ? nothing : html` ${t('files.badge.readonly')}`}
this._go( e.detail.rel ? `${e.detail.root}/${e.detail.rel}` : e.detail.root)} > `; } render() { if (!this._open) return nothing; return html`
${this._error ? html`
${this._error}
` : nothing}
${this._root ? this._renderExplorer() : this._renderRootList()}
`; } }