import { html, nothing } from 'lit'; import { LightElement } from '../../lib/base.js'; import { t } from '../../lib/i18n.js'; import { fileWatcher } from '../../lib/file-watcher.js'; /// The Files tab of a project board: a live explorer over the project folder. /// /// 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 the project folder (shown as `/`). The listing /// reloads in real time: the shared `/api/file/watch` socket (the `fileWatcher` /// singleton) pushes a `changed` event for the open directory whenever another /// member — or the agent, from inside its container — creates/modifies/removes /// a file in it. Write actions (new folder, upload, rename, delete) are offered /// only to members with `can_write` and are gated server-side too. export class ProjectFilesPanel extends LightElement { static properties = { project: { attribute: false }, _rel: { state: true }, _entries: { state: true }, _loading: { state: true }, _error: { state: true }, _busy: { state: true }, _modal: { state: true }, _drag: { state: true }, }; constructor() { super(); this.project = null; this._rel = ''; // path relative to the project root ('' = root) this._entries = null; 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) { // (Re)open the root only when the project itself changes — a refetch of the // same project (member edits) must not reset the current folder. if (changed.has('project')) { const prev = changed.get('project'); if (this.project?.root_path && this.project.root_path !== prev?.root_path) { this._open(''); } } } disconnectedCallback() { this._unwatch?.(); clearTimeout(this._reloadTimer); super.disconnectedCallback(); } _dirPath() { const root = this.project?.root_path ?? ''; return this._rel ? `${root}/${this._rel}` : 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.project?.root_path) 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()); this._entries = await res.json(); this._error = null; } catch (e) { this._error = e.message; } finally { this._loading = false; } } // ── Navigation ──────────────────────────────────────────────────────────── _enter(entry) { if (entry.is_dir) { this._open(this._rel ? `${this._rel}/${entry.name}` : entry.name); } else { window.openFile(entry.path); } } _goTo(index) { // -1 = project root, otherwise the segment index to land on. const segs = this._rel ? this._rel.split('/') : []; this._open(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('.pf-modal-input')?.focus()); } async _submitModal(e) { e.preventDefault(); const name = (this._modal?.name ?? '').trim(); if (!name || name.includes('/') || name.includes('\\')) { this._error = t('projects.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 ? 'projects.files.confirm.delete_dir' : 'projects.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('.pf-file-input')?.click(); } // ── Rendering ───────────────────────────────────────────────────────────── _renderBreadcrumb() { const segs = this._rel ? this._rel.split('/') : []; return html` `; } _renderToolbar() { const canWrite = !!this.project?.can_write; return html`
${t('projects.files.empty')}
| ${t('projects.files.col.name')} | ${t('projects.files.col.created')} | ${t('projects.files.col.modified')} | ${t('projects.files.col.size')} | ${canWrite ? html`` : nothing} |
|---|