Nightly Build / build (push) Canceled after 10m54s
The file viewer converts word-processor documents to PDF server-side via LibreOffice (skald_core::docx::DocxConverter), mirroring the LaTeX pipeline but content-hash cached: the format is self-contained, so there is no dependency graph and the file watcher needs no expansion. Container-only documents are shuttled out and converted on the host. With no LibreOffice installed the viewer says so and falls back to download-only. Downloads still save the original document, not the preview PDF.
476 lines
20 KiB
JavaScript
476 lines
20 KiB
JavaScript
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`
|
|
<nav class="d-flex align-items-center flex-wrap" aria-label="breadcrumb"
|
|
style="--bs-breadcrumb-divider: '/';">
|
|
<ol class="breadcrumb mb-0" style="font-size:0.9rem">
|
|
<li class="breadcrumb-item ${segs.length === 0 ? 'active' : ''}">
|
|
${segs.length === 0
|
|
? html`<span title=${this.root}><i class="bi bi-hdd me-1"></i>${this.rootLabel}</span>`
|
|
: html`<a href="#" @click=${e => { e.preventDefault(); this._goTo(-1); }}
|
|
title=${this.root}><i class="bi bi-hdd me-1"></i>${this.rootLabel}</a>`}
|
|
</li>
|
|
${segs.map((s, i) => html`
|
|
<li class="breadcrumb-item ${i === segs.length - 1 ? 'active' : ''}">
|
|
${i === segs.length - 1
|
|
? html`<span>${s}</span>`
|
|
: html`<a href="#" @click=${e => { e.preventDefault(); this._goTo(i); }}>${s}</a>`}
|
|
</li>
|
|
`)}
|
|
</ol>
|
|
</nav>
|
|
`;
|
|
}
|
|
|
|
_renderToolbar() {
|
|
return html`
|
|
<div class="d-flex align-items-center gap-2 mb-2">
|
|
${this._renderBreadcrumb()}
|
|
<div class="ms-auto d-flex gap-1">
|
|
<button class="btn btn-sm btn-outline-secondary" title=${t('files.refresh')}
|
|
?disabled=${this._loading} @click=${() => this._load()}>
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|
</button>
|
|
<a class="btn btn-sm btn-outline-secondary" download
|
|
href=${`/api/file/download?path=${encodeURIComponent(this._dirPath())}`}>
|
|
<i class="bi bi-file-zip me-1"></i>${t('files.btn.download')}
|
|
</a>
|
|
${this._canWrite ? html`
|
|
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
|
|
@click=${() => this._openModal('mkdir')}>
|
|
<i class="bi bi-folder-plus me-1"></i>${t('files.btn.new_folder')}
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-primary" ?disabled=${this._busy}
|
|
@click=${() => this._pickFiles()}>
|
|
${this._busy
|
|
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('files.uploading')}`
|
|
: html`<i class="bi bi-upload me-1"></i>${t('files.btn.upload')}`}
|
|
</button>
|
|
<input type="file" class="fx-file-input" multiple hidden
|
|
@change=${e => { this._uploadFiles([...e.target.files]); e.target.value = ''; }} />
|
|
` : nothing}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_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`
|
|
<tr style="cursor:pointer" @click=${() => this._enter(entry)}>
|
|
<td style="width:2rem"><i class="bi ${this._iconFor(entry)}"></i></td>
|
|
<td style="word-break:break-all">${entry.name}</td>
|
|
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.created_at)}</td>
|
|
<td class="text-muted text-nowrap" style="font-size:0.82rem">${this._fmtDate(entry.modified_at)}</td>
|
|
<td class="text-muted text-end text-nowrap" style="font-size:0.82rem">${entry.is_dir ? '—' : this._fmtSize(entry.size)}</td>
|
|
<td class="text-end text-nowrap" @click=${e => e.stopPropagation()}>
|
|
<a class="btn btn-sm btn-link text-secondary p-0 me-2" download
|
|
title=${t('files.action.download')}
|
|
href=${entry.is_dir
|
|
? `/api/file/download?path=${encodeURIComponent(entry.path)}`
|
|
: `/api/file?path=${encodeURIComponent(entry.path)}&force_download=true`}>
|
|
<i class="bi bi-download"></i>
|
|
</a>
|
|
${this._canWrite ? html`
|
|
<button class="btn btn-sm btn-link text-secondary p-0 me-2" title=${t('files.action.rename')}
|
|
@click=${() => this._openModal('rename', entry)}>
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-link text-danger p-0" title=${t('files.action.delete')}
|
|
?disabled=${this._busy} @click=${() => this._remove(entry)}>
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
` : nothing}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
_renderTable() {
|
|
if (!this._entries) {
|
|
return html`<div class="text-center py-4"><span class="spinner-border spinner-border-sm text-primary"></span></div>`;
|
|
}
|
|
if (this._entries.length === 0) {
|
|
return html`
|
|
<div class="text-center text-muted py-4">
|
|
<i class="bi bi-folder2-open" style="font-size:1.4rem"></i>
|
|
<p class="mb-0 mt-2" style="font-size:0.88rem">${t('files.empty')}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
return html`
|
|
<table class="table table-sm table-hover align-middle mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th>${t('files.col.name')}</th>
|
|
<th style="width:9.5rem">${t('files.col.created')}</th>
|
|
<th style="width:9.5rem">${t('files.col.modified')}</th>
|
|
<th class="text-end" style="width:5.5rem">${t('files.col.size')}</th>
|
|
<th style="width:6rem"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${this._entries.map(e => this._renderRow(e))}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
_renderModal() {
|
|
if (!this._modal) return nothing;
|
|
const isMkdir = this._modal.mode === 'mkdir';
|
|
return html`
|
|
<div class="agent-dialog-backdrop"
|
|
@click=${e => { if (e.target === e.currentTarget) this._modal = null; }}>
|
|
<div class="agent-dialog">
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
|
|
<i class="bi ${isMkdir ? 'bi-folder-plus' : 'bi-pencil'}"></i>
|
|
<span style="font-weight:600">
|
|
${isMkdir ? t('files.modal.mkdir') : t('files.modal.rename', { name: this._modal.target.name })}
|
|
</span>
|
|
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
|
|
@click=${() => this._modal = null}>
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
</div>
|
|
<form @submit=${e => this._submitModal(e)}>
|
|
<div class="mb-4">
|
|
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('files.modal.name')}</label>
|
|
<input type="text" class="form-control form-control-sm fx-modal-input" required
|
|
.value=${this._modal.name}
|
|
@input=${e => this._modal = { ...this._modal, name: e.target.value }} />
|
|
</div>
|
|
<div style="display:flex;justify-content:flex-end;gap:0.5rem">
|
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
@click=${() => this._modal = null}>${t('common.cancel')}</button>
|
|
<button type="submit" class="btn btn-sm btn-primary" ?disabled=${this._busy}>
|
|
<i class="bi bi-check-lg me-1"></i>${isMkdir ? t('common.create') : t('common.save')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
if (!this.root) return nothing;
|
|
return html`
|
|
<div class="card ${this._drag ? 'border-primary' : ''}"
|
|
@dragover=${e => { 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]); }}>
|
|
<div class="card-body">
|
|
${this._renderToolbar()}
|
|
${this._error ? html`
|
|
<div class="alert alert-danger py-2 mb-2" style="font-size:0.85rem">${this._error}</div>
|
|
` : nothing}
|
|
${this._drag ? html`
|
|
<div class="text-center text-primary py-3" style="font-size:0.9rem">
|
|
<i class="bi bi-cloud-arrow-up me-1"></i>${t('files.drop')}
|
|
</div>
|
|
` : this._renderTable()}
|
|
</div>
|
|
</div>
|
|
${this._renderModal()}
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define('file-explorer', FileExplorer);
|