Files
Daniele 488c702517
Nightly Build / build (push) Successful in 5m36s
feat(files): a Files section over the caller's whole space
Until now file browsing existed only inside a project, and the two memory
stores were reachable only by the agent's tools. `#files` is the general
surface: home, both memory stores, the shared folders and projects the
caller belongs to, plus the read-only skills and docs trees.

The root is virtual, and that is the design. Anchoring at `~` is wrong:
the explorer reads host-side, while `shared/`, `projects/`, `skills/` and
`docs/` are bind mounts inside the container — a page rooted at the home
would show less than the user has with no way to reach the rest, and on
native Linux would show Docker's empty mountpoint stubs, a door that
appears to work and leads nowhere. So level 0 is a synthetic list from the
new `GET /api/files/roots`, serialized from the caller's `UserFs` plus the
two virtual memory roots. It sends `kind`, never a label: labels are copy
and get translated.

`GET /api/files/dir` now answers `{ path, can_write, entries }`, and a
memory path is classified before `resolve_view_path` (which refuses one)
and listed from `memory_docs`: one level derived from the flat key space
by the pure `memory_docs::immediate_children`, over a single query whose
unslashed prefix also spots an exact note as "not a directory". Memory is
read-only from the page — every writer routes through `resolve_view_path`,
and `shared-memory/*` is `@fs_write require` for the agent, so a button
that walks past that rule is a decision of its own.

The explorer moves out of projects into `shared/file-explorer.js`, taking
`root` + `rootLabel` and reading `can_write` from the listing rather than
from its host: writability changes per branch and comes from the same
`UserFs::can_write_to` the server rejects writes with, so the buttons
offered and the writes accepted cannot disagree. Deep-linking needed it
steerable without a two-way binding, hence `rel` in and
`explorer-navigate` out — the event fires only for a click, never for a
`rel` the host set, so echoing it back is a no-op.

The URL carries the agent path of the open folder in one parameter, the
same vocabulary the assistant uses, so a link is shareable and pasteable
into a conversation; which root it belongs to is derived, not stored.

docs/: a new files.md, plus two pages this made false — shared-folders.md
claimed in three places that a shared folder has no explorer, and
memory.md never said a user can now read their own notes.
2026-08-22 20:09:56 +01:00

224 lines
8.6 KiB
JavaScript

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
/// `<file-explorer>`, 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`
<button class="connector-card text-start" style="cursor:pointer"
@click=${() => this._go(root.path)}>
<div class="d-flex align-items-center gap-3">
<i class="bi bi-${FilesPage.ICONS[root.kind] ?? 'folder'}"
style="font-size:1.15rem;opacity:.7"></i>
<div style="min-width:0">
<div style="font-weight:600;font-size:.95rem">${this._labelFor(root)}</div>
<code class="text-muted" style="font-size:.7rem">${root.path}</code>
</div>
<div class="ms-auto d-flex align-items-center gap-2">
${root.can_write ? nothing : html`
<span class="badge bg-secondary-subtle text-secondary-emphasis"
style="font-size:.68rem">${t('files.badge.readonly')}</span>`}
<i class="bi bi-chevron-right text-muted"></i>
</div>
</div>
</button>
`;
}
_renderRootList() {
if (this._roots === null) {
return html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t('common.loading')}</div>`;
}
return html`
<div class="text-muted mb-3" style="font-size:.78rem">
<i class="bi bi-info-circle me-1"></i>${t('files.note.roots')}
</div>
<div class="d-flex flex-column gap-2">
${this._roots.map(r => this._renderRootRow(r))}
</div>
`;
}
/// 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`
<div class="d-flex align-items-center gap-2 mb-3">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._go('')}>
<i class="bi bi-arrow-left me-1"></i>${t('files.back')}
</button>
<span style="font-weight:600">
<i class="bi bi-${FilesPage.ICONS[this._root.kind] ?? 'folder'} me-1"
style="opacity:.7"></i>${this._labelFor(this._root)}
</span>
${this._root.can_write ? nothing : html`
<span class="badge bg-secondary-subtle text-secondary-emphasis"
style="font-size:.68rem">${t('files.badge.readonly')}</span>`}
</div>
<file-explorer
.root=${this._root.path}
.rootLabel=${this._labelFor(this._root)}
.rel=${this._rel}
@explorer-navigate=${e => this._go(
e.detail.rel ? `${e.detail.root}/${e.detail.rel}` : e.detail.root)}
></file-explorer>
`;
}
render() {
if (!this._open) return nothing;
return html`
<div class="um-page">
<div class="page-header">
<div class="page-header-left">
<h2 class="page-header-title">
<i class="bi bi-folder2-open me-2"></i>${t('files.title')}
</h2>
</div>
</div>
${this._error ? html`
<div class="alert alert-danger py-2 mx-4" style="font-size:.85rem">${this._error}</div>` : nothing}
<div style="padding:0 1.25rem 1.5rem; overflow:auto">
${this._root ? this._renderExplorer() : this._renderRootList()}
</div>
</div>
`;
}
}