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`