import { html, nothing } from 'lit'; import { LightElement } from '../../lib/base.js'; import { t } from '../../lib/i18n.js'; import { fileWatcher } from '../../lib/file-watcher.js'; import { setSlice, clearSlice } from '../../lib/view-context.js'; /// The view-context slice this component owns (see `lib/view-context.js`). /// One explorer is on screen at a time — `#files` renders `nothing` when it is /// not the open page, and the project board is a page of its own — so a single /// key is right and two explorers cannot both claim it. const VIEW_SLICE = 'path'; const VIEW_LABEL = 'Open folder'; /// 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. /// /// **It also tells the assistant which folder is open.** The current directory /// is published as the `path` view-context slice, in agent-path vocabulary — the /// same string the fs-tools take — so "what is in this folder?" needs no /// explaining. That it works inside the project board as well as on `#files` is /// the whole reason the store is push-based: nobody threads a handle down here. /// The slice is cleared on `disconnectedCallback`, which is when both hosts drop /// the element (`#files` renders `nothing` while it is not the open page, and so /// does the projects page). The board hiding the explorer behind its *Sharing* /// tab leaves the slice standing, deliberately: the project's folder is still /// the folder the page is about, and which tab is showing is the board's own /// slice to publish. 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); clearSlice(VIEW_SLICE); super.disconnectedCallback(); } _dirPath() { return this._rel ? `${this.root}/${this._rel}` : this.root; } /// Say which folder is open, before the listing lands: the path is known the /// moment we navigate, and a message sent while the fetch is in flight is /// still a message about *this* folder. _publishViewContext() { if (!this.root) return; setSlice(VIEW_SLICE, [{ label: VIEW_LABEL, value: this._dirPath() }]); } async _open(rel) { this._unwatch?.(); this._unwatch = null; this._rel = rel; this._error = null; this._publishViewContext(); 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`
${this._renderBreadcrumb()}
${t('files.btn.download')} ${this._canWrite ? html` { this._uploadFiles([...e.target.files]); e.target.value = ''; }} /> ` : nothing}
`; } _iconFor(entry) { if (entry.is_dir) return 'bi-folder-fill text-warning'; const ext = entry.name.includes('.') ? entry.name.split('.').pop().toLowerCase() : ''; const map = { png: 'bi-file-image', jpg: 'bi-file-image', jpeg: 'bi-file-image', gif: 'bi-file-image', webp: 'bi-file-image', svg: 'bi-file-image', pdf: 'bi-file-pdf', md: 'bi-file-text', txt: 'bi-file-text', tex: 'bi-file-text', latex: 'bi-file-text', js: 'bi-file-code', ts: 'bi-file-code', py: 'bi-file-code', rs: 'bi-file-code', json: 'bi-file-code', html: 'bi-file-code', css: 'bi-file-code', sh: 'bi-file-code', zip: 'bi-file-zip', gz: 'bi-file-zip', tar: 'bi-file-zip', mp3: 'bi-file-music', wav: 'bi-file-music', ogg: 'bi-file-music', mp4: 'bi-file-play', mov: 'bi-file-play', webm: 'bi-file-play', doc: 'bi-file-word', docx: 'bi-file-word', odt: 'bi-file-word', rtf: 'bi-file-word', xls: 'bi-file-excel', xlsx: 'bi-file-excel', csv: 'bi-file-excel', }; return map[ext] ?? 'bi-file-earmark'; } _fmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); return isNaN(d) ? '—' : d.toLocaleString(); } _fmtSize(n) { if (n == null) return '—'; if (n < 1024) return `${n} B`; if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`; return `${(n / 1024 ** 3).toFixed(2)} GB`; } _renderRow(entry) { return html` this._enter(entry)}> ${entry.name} ${this._fmtDate(entry.created_at)} ${this._fmtDate(entry.modified_at)} ${entry.is_dir ? '—' : this._fmtSize(entry.size)} e.stopPropagation()}> ${this._canWrite ? html` ` : nothing} `; } _renderTable() { if (!this._entries) { return html`
`; } if (this._entries.length === 0) { return html`

${t('files.empty')}

`; } return html` ${this._entries.map(e => this._renderRow(e))}
${t('files.col.name')} ${t('files.col.created')} ${t('files.col.modified')} ${t('files.col.size')}
`; } _renderModal() { if (!this._modal) return nothing; const isMkdir = this._modal.mode === 'mkdir'; return html`
{ if (e.target === e.currentTarget) this._modal = null; }}>
${isMkdir ? t('files.modal.mkdir') : t('files.modal.rename', { name: this._modal.target.name })}
this._submitModal(e)}>
this._modal = { ...this._modal, name: e.target.value }} />
`; } render() { if (!this.root) return nothing; return html`
{ if (this._canWrite) { e.preventDefault(); this._drag = true; } }} @dragleave=${() => this._drag = false} @drop=${e => { e.preventDefault(); this._drag = false; if (this._canWrite) this._uploadFiles([...e.dataTransfer.files]); }}>
${this._renderToolbar()} ${this._error ? html`
${this._error}
` : nothing} ${this._drag ? html`
${t('files.drop')}
` : this._renderTable()}
${this._renderModal()} `; } } customElements.define('file-explorer', FileExplorer);