feat(files): a Files section over the caller's whole space
Nightly Build / build (push) Successful in 5m36s
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.
This commit is contained in:
@@ -29,6 +29,7 @@ import { LlmRequestDetail } from './components/llm-request-detail.js';
|
||||
import { SessionDetailPage } from './components/session-detail.js';
|
||||
import { SystemAgentsPage } from './components/system-agents.js';
|
||||
import { ProjectsPage } from './components/projects/index.js';
|
||||
import { FilesPage } from './components/files-page.js';
|
||||
import { FileViewerPage } from './components/file-viewer-page.js';
|
||||
import { ToolDetailPage } from './components/tool-detail-page.js';
|
||||
import { SetupPage } from './components/setup-page.js';
|
||||
@@ -79,6 +80,7 @@ customElements.define('llm-request-detail', LlmRequestDetail);
|
||||
customElements.define('session-detail-page', SessionDetailPage);
|
||||
customElements.define('system-agents-page', SystemAgentsPage);
|
||||
customElements.define('projects-page', ProjectsPage);
|
||||
customElements.define('files-page', FilesPage);
|
||||
customElements.define('file-viewer-page', FileViewerPage);
|
||||
customElements.define('tool-detail-page', ToolDetailPage);
|
||||
customElements.define('setup-page', SetupPage);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../../lib/base.js';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { ProjectFilesPanel } from './project-files.js';
|
||||
import '../shared/file-explorer.js';
|
||||
|
||||
/// A project's detail page: header + description, then two tabs — **Files** (a
|
||||
/// live explorer over the project folder, `<project-files-panel>`) and
|
||||
/// **Sharing** (member picker with read/write, mirroring the shared-folders UI).
|
||||
/// A project's detail page: header + description, then two tabs — **Files** (the
|
||||
/// shared `<file-explorer>`, pointed at the project folder) and **Sharing**
|
||||
/// (member picker with read/write, mirroring the shared-folders UI).
|
||||
export class ProjectBoardSection extends LightElement {
|
||||
static properties = {
|
||||
_project: { state: true },
|
||||
@@ -276,13 +276,11 @@ export class ProjectBoardSection extends LightElement {
|
||||
${this._renderTabs()}
|
||||
|
||||
<div class="p-3">
|
||||
<project-files-panel .project=${this._project}
|
||||
style=${this._tab === 'files' ? '' : 'display:none'}></project-files-panel>
|
||||
<file-explorer .root=${this._project.root_path ?? ''}
|
||||
style=${this._tab === 'files' ? '' : 'display:none'}></file-explorer>
|
||||
${this._tab === 'sharing' ? this._renderSharePanel() : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('project-files-panel', ProjectFilesPanel);
|
||||
|
||||
+102
-67
@@ -3,33 +3,60 @@ 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.
|
||||
/// 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 the project folder (shown as `/`). The listing
|
||||
/// 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 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 {
|
||||
/// 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 = {
|
||||
project: { attribute: false },
|
||||
_rel: { state: true },
|
||||
_entries: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_busy: { state: true },
|
||||
_modal: { state: true },
|
||||
_drag: { state: true },
|
||||
/// 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.project = null;
|
||||
this._rel = ''; // path relative to the project root ('' = root)
|
||||
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;
|
||||
@@ -41,14 +68,13 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@@ -58,8 +84,7 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
_dirPath() {
|
||||
const root = this.project?.root_path ?? '';
|
||||
return this._rel ? `${root}/${this._rel}` : root;
|
||||
return this._rel ? `${this.root}/${this._rel}` : this.root;
|
||||
}
|
||||
|
||||
async _open(rel) {
|
||||
@@ -81,13 +106,15 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
async _load() {
|
||||
if (!this.project?.root_path) return;
|
||||
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());
|
||||
this._entries = await res.json();
|
||||
this._error = null;
|
||||
const listing = await res.json();
|
||||
this._entries = listing.entries;
|
||||
this._canWrite = !!listing.can_write;
|
||||
this._error = null;
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
} finally {
|
||||
@@ -97,32 +124,41 @@ export class ProjectFilesPanel extends LightElement {
|
||||
|
||||
// ── 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._open(this._rel ? `${this._rel}/${entry.name}` : entry.name);
|
||||
this._navigate(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.
|
||||
// -1 = the 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('/'));
|
||||
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('.pf-modal-input')?.focus());
|
||||
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('projects.files.error.name');
|
||||
this._error = t('files.error.name');
|
||||
return;
|
||||
}
|
||||
this._busy = true;
|
||||
@@ -152,7 +188,7 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
async _remove(entry) {
|
||||
const key = entry.is_dir ? 'projects.files.confirm.delete_dir' : 'projects.files.confirm.delete_file';
|
||||
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 {
|
||||
@@ -189,7 +225,7 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
_pickFiles() {
|
||||
this.querySelector('.pf-file-input')?.click();
|
||||
this.querySelector('.fx-file-input')?.click();
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
@@ -202,9 +238,9 @@ export class ProjectFilesPanel extends LightElement {
|
||||
<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.project.root_path}><i class="bi bi-hdd me-1"></i>/</span>`
|
||||
? 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.project.root_path}><i class="bi bi-hdd me-1"></i>/</a>`}
|
||||
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' : ''}">
|
||||
@@ -219,31 +255,30 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
_renderToolbar() {
|
||||
const canWrite = !!this.project?.can_write;
|
||||
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('projects.files.refresh')}
|
||||
<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('projects.files.btn.download')}
|
||||
<i class="bi bi-file-zip me-1"></i>${t('files.btn.download')}
|
||||
</a>
|
||||
${canWrite ? html`
|
||||
${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('projects.files.btn.new_folder')}
|
||||
<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('projects.files.uploading')}`
|
||||
: html`<i class="bi bi-upload me-1"></i>${t('projects.files.btn.upload')}`}
|
||||
? 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="pf-file-input" multiple hidden
|
||||
<input type="file" class="fx-file-input" multiple hidden
|
||||
@change=${e => { this._uploadFiles([...e.target.files]); e.target.value = ''; }} />
|
||||
` : nothing}
|
||||
</div>
|
||||
@@ -285,7 +320,6 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
_renderRow(entry) {
|
||||
const canWrite = !!this.project?.can_write;
|
||||
return html`
|
||||
<tr style="cursor:pointer" @click=${() => this._enter(entry)}>
|
||||
<td style="width:2rem"><i class="bi ${this._iconFor(entry)}"></i></td>
|
||||
@@ -295,18 +329,18 @@ export class ProjectFilesPanel extends LightElement {
|
||||
<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('projects.files.action.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>
|
||||
${canWrite ? html`
|
||||
<button class="btn btn-sm btn-link text-secondary p-0 me-2" title=${t('projects.files.action.rename')}
|
||||
${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('projects.files.action.delete')}
|
||||
<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>
|
||||
@@ -324,7 +358,7 @@ export class ProjectFilesPanel extends LightElement {
|
||||
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('projects.files.empty')}</p>
|
||||
<p class="mb-0 mt-2" style="font-size:0.88rem">${t('files.empty')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -333,10 +367,10 @@ export class ProjectFilesPanel extends LightElement {
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>${t('projects.files.col.name')}</th>
|
||||
<th style="width:9.5rem">${t('projects.files.col.created')}</th>
|
||||
<th style="width:9.5rem">${t('projects.files.col.modified')}</th>
|
||||
<th class="text-end" style="width:5.5rem">${t('projects.files.col.size')}</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>
|
||||
@@ -357,7 +391,7 @@ export class ProjectFilesPanel extends LightElement {
|
||||
<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('projects.files.modal.mkdir') : t('projects.files.modal.rename', { name: this._modal.target.name })}
|
||||
${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}>
|
||||
@@ -366,16 +400,16 @@ export class ProjectFilesPanel extends LightElement {
|
||||
</div>
|
||||
<form @submit=${e => this._submitModal(e)}>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.files.modal.name')}</label>
|
||||
<input type="text" class="form-control form-control-sm pf-modal-input" required
|
||||
<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('projects.modal.cancel')}</button>
|
||||
@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('projects.modal.create') : t('projects.modal.save')}
|
||||
<i class="bi bi-check-lg me-1"></i>${isMkdir ? t('common.create') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -385,13 +419,12 @@ export class ProjectFilesPanel extends LightElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.project?.root_path) return nothing;
|
||||
const canWrite = !!this.project?.can_write;
|
||||
if (!this.root) return nothing;
|
||||
return html`
|
||||
<div class="card ${this._drag ? 'border-primary' : ''}"
|
||||
@dragover=${e => { if (canWrite) { e.preventDefault(); this._drag = true; } }}
|
||||
@dragover=${e => { if (this._canWrite) { e.preventDefault(); this._drag = true; } }}
|
||||
@dragleave=${() => this._drag = false}
|
||||
@drop=${e => { e.preventDefault(); this._drag = false; if (canWrite) this._uploadFiles([...e.dataTransfer.files]); }}>
|
||||
@drop=${e => { e.preventDefault(); this._drag = false; if (this._canWrite) this._uploadFiles([...e.dataTransfer.files]); }}>
|
||||
<div class="card-body">
|
||||
${this._renderToolbar()}
|
||||
${this._error ? html`
|
||||
@@ -399,7 +432,7 @@ export class ProjectFilesPanel extends LightElement {
|
||||
` : 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('projects.files.drop')}
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i>${t('files.drop')}
|
||||
</div>
|
||||
` : this._renderTable()}
|
||||
</div>
|
||||
@@ -408,3 +441,5 @@ export class ProjectFilesPanel extends LightElement {
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-explorer', FileExplorer);
|
||||
@@ -23,6 +23,10 @@ const NAV = [
|
||||
{ id: 'inbox', group: 'workspace', priority: 20, icon: 'inbox', labelKey: 'nav.inbox' },
|
||||
{ id: 'dashboard', group: 'workspace', priority: 30, icon: 'speedometer2', labelKey: 'nav.dashboard' },
|
||||
{ id: 'projects', group: 'workspace', priority: 40, icon: 'kanban', labelKey: 'nav.projects' },
|
||||
// Everything this person can reach on disk, plus their two memory stores.
|
||||
// Distinct from "Shared folders" below, which is the admin's CRUD *over* one
|
||||
// kind of them — hence the two different words in the copy.
|
||||
{ id: 'files', group: 'workspace', priority: 45, icon: 'folder2-open', labelKey: 'nav.files' },
|
||||
{ id: 'tasks', group: 'workspace', priority: 50, icon: 'lightning-charge',labelKey: 'nav.tasks' },
|
||||
// Shared folders is admin-managed but *content*, so it lives with the daily
|
||||
// items, not buried in Configuration — the link stays admin-gated per-entry.
|
||||
@@ -248,7 +252,7 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
||||
// `connector` (singular) is the per-connector detail page, `connectors` the list.
|
||||
// `plugin-catalog` is the pre-merge hash of what is now `#plugins`.
|
||||
const page = segment === 'plugin-catalog' ? 'plugins' : segment;
|
||||
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-detail', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'].includes(page) ? page : 'home';
|
||||
return ['inbox', 'dashboard', 'tasks', 'projects', 'files', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-detail', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'system-agents', 'file_viewer', 'tool_detail'].includes(page) ? page : 'home';
|
||||
}
|
||||
|
||||
_tasksSectionFromHash() {
|
||||
|
||||
@@ -75,6 +75,7 @@ tool-detail-page {
|
||||
users-page,
|
||||
roles-page,
|
||||
shared-folders-page,
|
||||
files-page,
|
||||
connectors-page,
|
||||
connector-detail-page,
|
||||
plugin-catalog-page,
|
||||
|
||||
+36
-20
@@ -8,6 +8,7 @@ export default {
|
||||
'nav.inbox': 'Inbox',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.projects': 'Projects',
|
||||
'nav.files': 'Files',
|
||||
'nav.tasks': 'Task Manager',
|
||||
'nav.tasks.running': 'Running Tasks',
|
||||
'nav.tasks.cron': 'Cron Jobs',
|
||||
@@ -266,26 +267,40 @@ export default {
|
||||
'projects.share.access.readwrite':'Read & write',
|
||||
'projects.tabs.files': 'Files',
|
||||
'projects.tabs.sharing': 'Sharing',
|
||||
'projects.files.col.name': 'Name',
|
||||
'projects.files.col.created': 'Created',
|
||||
'projects.files.col.modified': 'Modified',
|
||||
'projects.files.col.size': 'Size',
|
||||
'projects.files.empty': 'This folder is empty.',
|
||||
'projects.files.refresh': 'Refresh',
|
||||
'projects.files.drop': 'Drop files here to upload',
|
||||
'projects.files.btn.new_folder': 'New folder',
|
||||
'projects.files.btn.upload': 'Upload',
|
||||
'projects.files.btn.download': 'Download ZIP',
|
||||
'projects.files.uploading': 'Uploading…',
|
||||
'projects.files.action.download': 'Download',
|
||||
'projects.files.action.rename': 'Rename',
|
||||
'projects.files.action.delete': 'Delete',
|
||||
'projects.files.confirm.delete_file': 'Delete "{name}"?',
|
||||
'projects.files.confirm.delete_dir': 'Delete the folder "{name}" and everything inside it?',
|
||||
'projects.files.modal.mkdir': 'New folder',
|
||||
'projects.files.modal.rename': 'Rename "{name}"',
|
||||
'projects.files.modal.name': 'Name',
|
||||
'projects.files.error.name': 'Enter a valid name (no slashes).',
|
||||
|
||||
// ── File explorer (shared component: projects, Files section) ───────────────
|
||||
'files.col.name': 'Name',
|
||||
'files.col.created': 'Created',
|
||||
'files.col.modified': 'Modified',
|
||||
'files.col.size': 'Size',
|
||||
'files.empty': 'This folder is empty.',
|
||||
'files.refresh': 'Refresh',
|
||||
'files.drop': 'Drop files here to upload',
|
||||
'files.btn.new_folder': 'New folder',
|
||||
'files.btn.upload': 'Upload',
|
||||
'files.btn.download': 'Download ZIP',
|
||||
'files.uploading': 'Uploading…',
|
||||
'files.action.download': 'Download',
|
||||
'files.action.rename': 'Rename',
|
||||
'files.action.delete': 'Delete',
|
||||
'files.confirm.delete_file': 'Delete "{name}"?',
|
||||
'files.confirm.delete_dir': 'Delete the folder "{name}" and everything inside it?',
|
||||
'files.modal.mkdir': 'New folder',
|
||||
'files.modal.rename': 'Rename "{name}"',
|
||||
'files.modal.name': 'Name',
|
||||
'files.error.name': 'Enter a valid name (no slashes).',
|
||||
'files.title': 'Files',
|
||||
'files.note.roots': 'Everywhere you can go: your home, your two memory stores, and the folders shared with you. These are the same paths the assistant uses.',
|
||||
'files.back': 'All files',
|
||||
'files.badge.readonly': 'Read-only',
|
||||
'files.error.unknown_path': '"{path}" is not one of your folders.',
|
||||
'files.root.home': 'Home',
|
||||
'files.root.user_memory': 'Personal memory',
|
||||
'files.root.shared_memory': 'Shared memory',
|
||||
'files.root.shared': 'Shared folder',
|
||||
'files.root.project': 'Project',
|
||||
'files.root.skills': 'Skills',
|
||||
'files.root.docs': 'Documentation',
|
||||
|
||||
// ── Project detail ──────────────────────────────────────────────────────────
|
||||
'project_board.back': 'Projects',
|
||||
@@ -1226,6 +1241,7 @@ export default {
|
||||
'common.cancel': 'Cancel',
|
||||
'common.loading': 'Loading…',
|
||||
'common.saved': 'Saved',
|
||||
'common.create': 'Create',
|
||||
|
||||
// ── Shared Folders (blueprint §6) ────────────────────────────────────────────
|
||||
'nav.shared_folders': 'Shared Folders',
|
||||
|
||||
+36
-20
@@ -8,6 +8,7 @@ export default {
|
||||
'nav.inbox': 'Boîte de réception',
|
||||
'nav.dashboard': 'Tableau de bord',
|
||||
'nav.projects': 'Projets',
|
||||
'nav.files': 'Fichiers',
|
||||
'nav.tasks': 'Gestionnaire de tâches',
|
||||
'nav.tasks.running': 'Tâches en cours',
|
||||
'nav.tasks.cron': 'Tâches Cron',
|
||||
@@ -266,26 +267,40 @@ export default {
|
||||
'projects.share.access.readwrite':'Lecture et écriture',
|
||||
'projects.tabs.files': 'Fichiers',
|
||||
'projects.tabs.sharing': 'Partage',
|
||||
'projects.files.col.name': 'Nom',
|
||||
'projects.files.col.created': 'Création',
|
||||
'projects.files.col.modified': 'Modification',
|
||||
'projects.files.col.size': 'Taille',
|
||||
'projects.files.empty': 'Ce dossier est vide.',
|
||||
'projects.files.refresh': 'Actualiser',
|
||||
'projects.files.drop': 'Déposez les fichiers ici pour les envoyer',
|
||||
'projects.files.btn.new_folder': 'Nouveau dossier',
|
||||
'projects.files.btn.upload': 'Envoyer',
|
||||
'projects.files.btn.download': 'Télécharger (ZIP)',
|
||||
'projects.files.uploading': 'Envoi…',
|
||||
'projects.files.action.download': 'Télécharger',
|
||||
'projects.files.action.rename': 'Renommer',
|
||||
'projects.files.action.delete': 'Supprimer',
|
||||
'projects.files.confirm.delete_file': 'Supprimer « {name} » ?',
|
||||
'projects.files.confirm.delete_dir': 'Supprimer le dossier « {name} » et tout son contenu ?',
|
||||
'projects.files.modal.mkdir': 'Nouveau dossier',
|
||||
'projects.files.modal.rename': 'Renommer « {name} »',
|
||||
'projects.files.modal.name': 'Nom',
|
||||
'projects.files.error.name': 'Saisissez un nom valide (sans barres obliques).',
|
||||
|
||||
// ── File explorer (shared component: projects, Files section) ───────────────
|
||||
'files.col.name': 'Nom',
|
||||
'files.col.created': 'Création',
|
||||
'files.col.modified': 'Modification',
|
||||
'files.col.size': 'Taille',
|
||||
'files.empty': 'Ce dossier est vide.',
|
||||
'files.refresh': 'Actualiser',
|
||||
'files.drop': 'Déposez les fichiers ici pour les envoyer',
|
||||
'files.btn.new_folder': 'Nouveau dossier',
|
||||
'files.btn.upload': 'Envoyer',
|
||||
'files.btn.download': 'Télécharger (ZIP)',
|
||||
'files.uploading': 'Envoi…',
|
||||
'files.action.download': 'Télécharger',
|
||||
'files.action.rename': 'Renommer',
|
||||
'files.action.delete': 'Supprimer',
|
||||
'files.confirm.delete_file': 'Supprimer « {name} » ?',
|
||||
'files.confirm.delete_dir': 'Supprimer le dossier « {name} » et tout son contenu ?',
|
||||
'files.modal.mkdir': 'Nouveau dossier',
|
||||
'files.modal.rename': 'Renommer « {name} »',
|
||||
'files.modal.name': 'Nom',
|
||||
'files.error.name': 'Saisissez un nom valide (sans barres obliques).',
|
||||
'files.title': 'Fichiers',
|
||||
'files.note.roots': 'Tout ce que vous pouvez atteindre : votre dossier personnel, vos deux mémoires et les dossiers partagés avec vous. Ce sont les chemins qu\'utilise l\'assistant.',
|
||||
'files.back': 'Tous les fichiers',
|
||||
'files.badge.readonly': 'Lecture seule',
|
||||
'files.error.unknown_path': '« {path} » ne fait pas partie de vos dossiers.',
|
||||
'files.root.home': 'Dossier personnel',
|
||||
'files.root.user_memory': 'Mémoire personnelle',
|
||||
'files.root.shared_memory': 'Mémoire partagée',
|
||||
'files.root.shared': 'Dossier partagé',
|
||||
'files.root.project': 'Projet',
|
||||
'files.root.skills': 'Compétences',
|
||||
'files.root.docs': 'Documentation',
|
||||
|
||||
// ── Détail du projet ────────────────────────────────────────────────────────
|
||||
'project_board.back': 'Projets',
|
||||
@@ -1213,6 +1228,7 @@ export default {
|
||||
'common.cancel': 'Annuler',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.saved': 'Enregistré',
|
||||
'common.create': 'Créer',
|
||||
|
||||
// ── Shared Folders (blueprint §6) ────────────────────────────────────────────
|
||||
'nav.shared_folders': 'Dossiers partagés',
|
||||
|
||||
+36
-20
@@ -8,6 +8,7 @@ export default {
|
||||
'nav.inbox': 'Richieste',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.projects': 'Progetti',
|
||||
'nav.files': 'File',
|
||||
'nav.tasks': 'Gestione attività',
|
||||
'nav.tasks.running': 'Attività in corso',
|
||||
'nav.tasks.cron': 'Attività ricorrenti',
|
||||
@@ -290,26 +291,40 @@ export default {
|
||||
'projects.share.access.readwrite':'Lettura e scrittura',
|
||||
'projects.tabs.files': 'File',
|
||||
'projects.tabs.sharing': 'Condivisione',
|
||||
'projects.files.col.name': 'Nome',
|
||||
'projects.files.col.created': 'Creazione',
|
||||
'projects.files.col.modified': 'Ultima modifica',
|
||||
'projects.files.col.size': 'Dimensione',
|
||||
'projects.files.empty': 'Questa cartella è vuota.',
|
||||
'projects.files.refresh': 'Aggiorna',
|
||||
'projects.files.drop': 'Trascina qui i file per caricarli',
|
||||
'projects.files.btn.new_folder': 'Nuova cartella',
|
||||
'projects.files.btn.upload': 'Carica',
|
||||
'projects.files.btn.download': 'Scarica ZIP',
|
||||
'projects.files.uploading': 'Caricamento…',
|
||||
'projects.files.action.download': 'Scarica',
|
||||
'projects.files.action.rename': 'Rinomina',
|
||||
'projects.files.action.delete': 'Elimina',
|
||||
'projects.files.confirm.delete_file': 'Eliminare "{name}"?',
|
||||
'projects.files.confirm.delete_dir': 'Eliminare la cartella "{name}" e tutto il suo contenuto?',
|
||||
'projects.files.modal.mkdir': 'Nuova cartella',
|
||||
'projects.files.modal.rename': 'Rinomina "{name}"',
|
||||
'projects.files.modal.name': 'Nome',
|
||||
'projects.files.error.name': 'Inserisci un nome valido (senza barre).',
|
||||
|
||||
// ── File explorer (shared component: projects, Files section) ───────────────
|
||||
'files.col.name': 'Nome',
|
||||
'files.col.created': 'Creazione',
|
||||
'files.col.modified': 'Ultima modifica',
|
||||
'files.col.size': 'Dimensione',
|
||||
'files.empty': 'Questa cartella è vuota.',
|
||||
'files.refresh': 'Aggiorna',
|
||||
'files.drop': 'Trascina qui i file per caricarli',
|
||||
'files.btn.new_folder': 'Nuova cartella',
|
||||
'files.btn.upload': 'Carica',
|
||||
'files.btn.download': 'Scarica ZIP',
|
||||
'files.uploading': 'Caricamento…',
|
||||
'files.action.download': 'Scarica',
|
||||
'files.action.rename': 'Rinomina',
|
||||
'files.action.delete': 'Elimina',
|
||||
'files.confirm.delete_file': 'Eliminare "{name}"?',
|
||||
'files.confirm.delete_dir': 'Eliminare la cartella "{name}" e tutto il suo contenuto?',
|
||||
'files.modal.mkdir': 'Nuova cartella',
|
||||
'files.modal.rename': 'Rinomina "{name}"',
|
||||
'files.modal.name': 'Nome',
|
||||
'files.error.name': 'Inserisci un nome valido (senza barre).',
|
||||
'files.title': 'File',
|
||||
'files.note.roots': 'Tutto quello che puoi raggiungere: la tua home, le tue due memorie e le cartelle condivise con te. Sono gli stessi percorsi che usa l\'assistente.',
|
||||
'files.back': 'Tutti i file',
|
||||
'files.badge.readonly': 'Sola lettura',
|
||||
'files.error.unknown_path': '"{path}" non è una delle tue cartelle.',
|
||||
'files.root.home': 'Home',
|
||||
'files.root.user_memory': 'Memoria personale',
|
||||
'files.root.shared_memory': 'Memoria condivisa',
|
||||
'files.root.shared': 'Cartella condivisa',
|
||||
'files.root.project': 'Progetto',
|
||||
'files.root.skills': 'Skill',
|
||||
'files.root.docs': 'Documentazione',
|
||||
|
||||
// ── Dettaglio progetto ──────────────────────────────────────────────────────
|
||||
'project_board.back': 'Progetti',
|
||||
@@ -1213,6 +1228,7 @@ export default {
|
||||
'common.cancel': 'Annulla',
|
||||
'common.loading': 'Caricamento…',
|
||||
'common.saved': 'Salvato',
|
||||
'common.create': 'Crea',
|
||||
|
||||
// ── Cartelle condivise (blueprint §6) ────────────────────────────────────────
|
||||
'nav.shared_folders': 'Cartelle condivise',
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
<session-detail-page style="display:none"></session-detail-page>
|
||||
<system-agents-page style="display:none"></system-agents-page>
|
||||
<projects-page style="display:none"></projects-page>
|
||||
<files-page style="display:none"></files-page>
|
||||
<file-viewer-page style="display:none"></file-viewer-page>
|
||||
<tool-detail-page style="display:none"></tool-detail-page>
|
||||
<app-copilot></app-copilot>
|
||||
|
||||
Reference in New Issue
Block a user