Nightly Build / build (push) Successful in 5m36s
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.
224 lines
8.6 KiB
JavaScript
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>
|
|
`;
|
|
}
|
|
}
|