import { html, nothing } from 'lit'; import { LightElement } from '../../lib/base.js'; import { t } from '../../lib/i18n.js'; import { fileWatcher } from '../../lib/file-watcher.js'; /// A live file explorer over one subtree of the caller's namespace. /// /// One directory at a time (`GET /api/files/dir`); clicking a folder navigates /// into it, clicking a file opens it in the existing viewer (`window.openFile`). /// The breadcrumb is rooted at [`root`] (an agent path — a project folder, a /// shared folder, the home, a memory store), shown as `rootLabel`. The listing /// reloads in real time: the shared `/api/file/watch` socket (the `fileWatcher` /// singleton) pushes a `changed` event for the open directory whenever someone /// else — or the agent, from inside its container — creates/modifies/removes a /// file in it. /// /// **Writability is read from the listing, never passed in.** It changes per /// branch (a shared folder without `can_write`, `skills/`, `docs/`, a memory /// store), and `/api/files/dir` answers it from the same `UserFs::can_write_to` /// that the server rejects writes with — so the buttons this offers and the /// writes the server accepts cannot disagree. A caller that thinks it knows /// better would be the one place they could. /// /// **The current folder is readable and settable, without a two-way binding.** /// A host that puts the path in the URL (`files-page.js`) needs both halves: /// `rel` steers the explorer when the hash changes (a deep link, the browser's /// back button), and `explorer-navigate` reports where the user just went. The /// loop those two would otherwise form is cut by *what the event means*: it /// fires only for a click, never for a `rel` the host itself set — so echoing /// the event back as a property is a no-op, and a host that ignores the event /// entirely (`project-board.js`) still gets a working explorer. export class FileExplorer extends LightElement { static properties = { /// Agent path of the subtree to browse (`~`, `shared/x`, `projects/a/b`, /// `user-memory`…). Changing it navigates back to that root. root: { type: String }, /// What the first breadcrumb crumb reads; the full `root` is its tooltip. rootLabel: { type: String }, /// Folder to show, relative to `root` (`''` = the root itself). Optional: /// leave it unset and the explorer simply keeps its own place. rel: { type: String }, _rel: { state: true }, _entries: { state: true }, _canWrite: { state: true }, _loading: { state: true }, _error: { state: true }, _busy: { state: true }, _modal: { state: true }, _drag: { state: true }, }; constructor() { super(); this.root = ''; this.rootLabel = '/'; this.rel = ''; this._rel = ''; // path relative to `root` ('' = the root itself) this._entries = null; this._canWrite = false; this._loading = false; this._error = null; this._busy = false; this._modal = null; // { mode: 'mkdir'|'rename', name, target? } this._drag = false; this._unwatch = null; this._reloadTimer = null; this._onChanged = () => this._scheduleReload(); } willUpdate(changed) { if (!this.root) return; // Re-anchor only on a real move: a re-render with the same root must not // throw away the folder the user navigated to, and a `rel` echoing back the // click that produced it is already where it says (see the class comment). const movedRoot = changed.has('root') && this.root !== changed.get('root'); const movedRel = changed.has('rel') && this.rel !== this._rel; if (movedRoot || movedRel) this._open(this.rel ?? ''); } disconnectedCallback() { this._unwatch?.(); clearTimeout(this._reloadTimer); super.disconnectedCallback(); } _dirPath() { return this._rel ? `${this.root}/${this._rel}` : this.root; } async _open(rel) { this._unwatch?.(); this._unwatch = null; this._rel = rel; this._error = null; await this._load(); // Live updates for the open directory (best-effort: a dead watcher just // means manual refresh; auto-reconnect + re-subscribe are handled inside). try { this._unwatch = await fileWatcher.watch(this._dirPath(), this._onChanged); } catch { this._unwatch = null; } } _scheduleReload() { clearTimeout(this._reloadTimer); this._reloadTimer = setTimeout(() => this._load(), 300); } async _load() { if (!this.root) return; this._loading = true; try { const res = await fetch(`/api/files/dir?path=${encodeURIComponent(this._dirPath())}`); if (!res.ok) throw new Error(await res.text()); const listing = await res.json(); this._entries = listing.entries; this._canWrite = !!listing.can_write; this._error = null; } catch (e) { this._error = e.message; } finally { this._loading = false; } } // ── Navigation ──────────────────────────────────────────────────────────── /// A move the **user** made: go, then say so. Only this path announces — /// `_open` stays the silent mechanism the property sync uses. _navigate(rel) { this._open(rel); this.dispatchEvent(new CustomEvent('explorer-navigate', { bubbles: true, composed: true, detail: { root: this.root, rel }, })); } _enter(entry) { if (entry.is_dir) { this._navigate(this._rel ? `${this._rel}/${entry.name}` : entry.name); } else { window.openFile(entry.path); } } _goTo(index) { // -1 = the root, otherwise the segment index to land on. const segs = this._rel ? this._rel.split('/') : []; this._navigate(index < 0 ? '' : segs.slice(0, index + 1).join('/')); } // ── Write actions ───────────────────────────────────────────────────────── _openModal(mode, target = null) { this._modal = { mode, name: target?.name ?? '', target }; this.updateComplete.then(() => this.querySelector('.fx-modal-input')?.focus()); } async _submitModal(e) { e.preventDefault(); const name = (this._modal?.name ?? '').trim(); if (!name || name.includes('/') || name.includes('\\')) { this._error = t('files.error.name'); return; } this._busy = true; try { let res; if (this._modal.mode === 'mkdir') { res = await fetch('/api/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: `${this._dirPath()}/${name}`, dir: true }), }); } else { res = await fetch('/api/file', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old_path: this._modal.target.path, new_path: `${this._dirPath()}/${name}` }), }); } if (!res.ok) throw new Error(await res.text()); this._modal = null; await this._load(); } catch (err) { this._error = err.message; } finally { this._busy = false; } } async _remove(entry) { const key = entry.is_dir ? 'files.confirm.delete_dir' : 'files.confirm.delete_file'; if (!confirm(t(key, { name: entry.name }))) return; this._busy = true; try { const res = await fetch(`/api/file?path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } async _uploadFiles(files) { if (!files?.length) return; this._busy = true; this._error = null; try { for (const f of files) { const target = `${this._dirPath()}/${f.name}`; const res = await fetch(`/api/file/upload?path=${encodeURIComponent(target)}`, { method: 'POST', body: f, }); if (!res.ok) throw new Error(`${f.name}: ${await res.text()}`); } // The watcher will also fire; reload now in case it is down. await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } _pickFiles() { this.querySelector('.fx-file-input')?.click(); } // ── Rendering ───────────────────────────────────────────────────────────── _renderBreadcrumb() { const segs = this._rel ? this._rel.split('/') : []; return html` `; } _renderToolbar() { return html`
${t('files.empty')}
| ${t('files.col.name')} | ${t('files.col.created')} | ${t('files.col.modified')} | ${t('files.col.size')} |
|---|