feat(files): a Files section over the caller's whole space
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:
Daniele
2026-08-22 20:09:56 +01:00
parent 934726a75d
commit 488c702517
19 changed files with 870 additions and 148 deletions
+6
View File
@@ -10,6 +10,12 @@ release PR may merge — and a section is closed at the commit that bumps it.
### Added ### Added
- A **Files** section in the menu: everywhere you can reach, in one place — your home,
your personal and the shared memory, the folders and projects shared with you, plus
skills and documentation. Browse, open, download a folder as a ZIP, and upload, rename
or delete wherever you have write access; the read-only places say so. Your memory
notes are readable here for the first time (changing them still goes through the
assistant).
- Several conversations per source: open extra chats with `+`, and the tab bar you left - Several conversations per source: open extra chats with `+`, and the tab bar you left
open is restored at your next login, on any device. open is restored at your next login, on any device.
- A background task now reports back into the chat that started it instead of only the - A background task now reports back into the chat that started it instead of only the
+18 -2
View File
@@ -194,9 +194,23 @@ A **project** is a shareable, self-service workspace: a folder at `{WD}/projects
**API** (`src/frontend/api/projects.rs`): `GET/POST /api/projects`, `GET/PUT/DELETE /api/projects/{id}`, `POST /api/projects/{id}/members`, `DELETE .../members/{user_id}`, `POST /api/projects/{id}/session`. `ProjectDetail` carries `root_path` — the agent path of the folder, computed server-side (owner username ≠ `owner_name`, which may be a display name) — the explorer's root. A `project-{id}` chat source provisions the `project-coordinator` agent with a project `RunContext` (`provisioning_for_source``skald_core::projects::build_project_run_context`: `project_root` + a system block with name/description/folder/members); every member keeps their **own private** `project-{id}` session — only the folder is shared. **API** (`src/frontend/api/projects.rs`): `GET/POST /api/projects`, `GET/PUT/DELETE /api/projects/{id}`, `POST /api/projects/{id}/members`, `DELETE .../members/{user_id}`, `POST /api/projects/{id}/session`. `ProjectDetail` carries `root_path` — the agent path of the folder, computed server-side (owner username ≠ `owner_name`, which may be a display name) — the explorer's root. A `project-{id}` chat source provisions the `project-coordinator` agent with a project `RunContext` (`provisioning_for_source``skald_core::projects::build_project_run_context`: `project_root` + a system block with name/description/folder/members); every member keeps their **own private** `project-{id}` session — only the folder is shared.
**UI** (`web/components/projects/`): `index.js` (`<projects-page>` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`<project-board-section>` — the detail page: header with **Open chat**, then a **Files / Sharing** tab bar using the `.project-tab-bar` styles in `css/projects/board.css`), `project-files.js` (`<project-files-panel>` — the explorer). The mobile app has its own read-only `shared/projects-page.js` (list → open project chat). **UI** (`web/components/projects/`): `index.js` (`<projects-page>` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`<project-board-section>` — the detail page: header with **Open chat**, then a **Files / Sharing** tab bar using the `.project-tab-bar` styles in `css/projects/board.css`, the Files tab being the shared `<file-explorer>` pointed at the project folder). The mobile app has its own read-only `shared/projects-page.js` (list → open project chat).
**The explorer** (`project-files.js`): one directory at a time via `GET /api/files/dir?path=…` (new endpoint in `src/frontend/api/files.rs`: immediate children with `name/path/is_dir/size/created_at/modified_at`, dirs-first; same `resolve_view_path` scoping as `/api/file`). Breadcrumb rooted at the project (`/` = `root_path`); file click → `window.openFile` (existing viewer); folder click → navigate. **Live**: it subscribes the open directory on the existing `/api/file/watch` socket (`web/lib/file-watcher.js` singleton — `notify` NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to `can_write` members and ride the existing `/api/file` endpoints — `POST` gained `dir:true` (mkdir), `DELETE` handles directories (`remove_dir_all`), and binary upload is the new `POST /api/file/upload?path=…` (raw body, 256 MiB `DefaultBodyLimit`). **Server-side write gate**: all `/api/file` write handlers now call `UserFs::can_write_to(agent_path)` (core-api) — home → true, `shared/`/`projects/` → the membership's `can_write`, `docs/` → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes). **The explorer** (`web/components/shared/file-explorer.js`, `<file-explorer>`): **not a project component** — it browses one subtree of the caller's namespace, given a `root` agent path (a project folder, a shared folder, the home, a memory store) and a `rootLabel` for the first crumb; projects are one caller of it. One directory at a time via `GET /api/files/dir?path=…` (`src/frontend/api/files.rs`: `{ path, can_write, entries }`, each entry `name/path/is_dir/size/created_at/modified_at`, dirs-first; same `resolve_view_path` scoping as `/api/file`, except a memory path, classified **before** it and listed from `memory_docs` — see the memory-namespace note). **`can_write` is read from that listing, never passed in**: it changes per branch (a shared folder without the flag, `skills/`, `docs/`, a memory store) and comes from the same `UserFs::can_write_to` the server rejects writes with, so the buttons offered and the writes accepted cannot disagree — a caller that thought it knew better would be the one place they could. Breadcrumb rooted at `root`; file click → `window.openFile` (existing viewer); folder click → navigate. **Live**: it subscribes the open directory on the existing `/api/file/watch` socket (`web/lib/file-watcher.js` singleton — `notify` NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to `can_write` members and ride the existing `/api/file` endpoints — `POST` gained `dir:true` (mkdir), `DELETE` handles directories (`remove_dir_all`), and binary upload is the new `POST /api/file/upload?path=…` (raw body, 256 MiB `DefaultBodyLimit`). **Server-side write gate**: all `/api/file` write handlers now call `UserFs::can_write_to(agent_path)` (core-api) — home → true, `shared/`/`projects/` → the membership's `can_write`, `docs/` → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes).
## Files (`#files`)
The general file section: one page over **everything the caller can reach**, and the second consumer of `<file-explorer>` (see the Projects section for the component itself).
**The root is virtual, and that is the whole design.** Anchoring at `~` was the obvious move and is wrong: the explorer reads **host-side**, where the home is `{WD}/homes/{userid}` while `shared/{X}`, `projects/{O}/{S}`, `skills/` and `docs/` are bind mounts *inside the container* — so 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 the mountpoint stubs Docker creates in the bind source: `shared/`, `docs/`, `skills/` present and **empty**. That is the memory-signpost failure exactly — a door that appears to work and leads nowhere. So level 0 is a synthetic list from `GET /api/files/roots`, serialized from the caller's `UserFs` (plus the two memory roots, which are virtual and so are not in it): `FsRoot { kind, path, name, owner, can_write }`, with **no `label`** — the server sends the discriminant, the frontend maps `kind` → label + icon, because labels are copy and get translated. Seven kinds, not six: `user-memory` and `shared-memory` are separate rather than one `memory` with a scope, since they are two stores with two names and a `scope` field would be a discriminant inside a discriminant. `skills`/`docs` appear only if the `UserFs` has them.
**The URL carries the agent path of the open folder** — one `path` parameter (`#files?path=shared/casa/foto`), the same vocabulary the assistant uses, so a link is shareable *and* pasteable into a conversation. Which root it belongs to is **derived** (`FilesPage._resolve`, longest-prefix over the roots list), never stored beside it: two values that can disagree are two chances to be wrong. A path under no root — a hand-edited URL, or a container-only `/tmp/…`, which this page does not serve — falls back to the root list with an error, rather than to an explorer that cannot explain itself.
**Deep-linking needed the explorer to be steerable without a two-way binding**, hence `rel` in + `explorer-navigate` out. The loop those two would form is cut by *what the event means*: it fires only for a click (`_navigate`), never for a `rel` the host set (`_open`), so echoing the event back as a property is a no-op — and a host that ignores the event entirely (the project board) still gets a working explorer.
**Memory is read-only here**, and it is scope rather than a property (blueprint `dir-explorer.md` task 5): every writer in `files.rs` routes through `resolve_view_path`, which refuses memory paths, and `shared-memory/*` is `@fs_write require` for the agent — giving a user a button that walks past that rule is a decision of its own. The listing side *is* wired: `list_dir` classifies memory **before** `resolve_view_path` and derives one level from the flat key space via `memory_docs::immediate_children`.
**Naming trap in the sidebar**: the `workspace` group already holds "Shared folders", which is the admin's CRUD *over one kind of root* — not this. The two entries must stay obviously different in copy.
## MCP connectors (blueprint §7/§14/§15) ## MCP connectors (blueprint §7/§14/§15)
@@ -520,6 +534,8 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che
| `shared/config-form.js` | `ConfigFormController` | The schema-driven settings form, shared by `config-page.js` and the System agents page — one renderer and one write path (`PUT /api/config/{key}`) for every `ConfigSet` | | `shared/config-form.js` | `ConfigFormController` | The schema-driven settings form, shared by `config-page.js` and the System agents page — one renderer and one write path (`PUT /api/config/{key}`) for every `ConfigSet` |
| `shared-folders.js` | `<shared-folders-page>` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context | | `shared-folders.js` | `<shared-folders-page>` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context |
| `projects/` | `<projects-page>` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section | | `projects/` | `<projects-page>` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section |
| `files-page.js` | `<files-page>` | `#files` — the caller's whole space. Level 0 is the **virtual root** (`GET /api/files/roots`), level 1 the shared `<file-explorer>`. See the Files section |
| `shared/file-explorer.js` | `<file-explorer>` | The explorer itself, host-agnostic: `root` + `rootLabel` + optional `rel`, `can_write` read from the listing. Used by `#files` and the project board |
| `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section, with the plugin grants right below it), so "who has what" has a single surface | | `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section, with the plugin grants right below it), so "who has what" has a single surface |
| `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch | | `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch |
| `llm-providers.js` | `<llm-providers-page>` | LLM provider management | | `llm-providers.js` | `<llm-providers-page>` | LLM provider management |
+166 -1
View File
@@ -42,6 +42,24 @@ pub struct MemoryEntryMeta {
pub path: String, pub path: String,
pub line_count: i64, pub line_count: i64,
pub byte_len: i64, pub byte_len: i64,
pub created_at: String,
pub updated_at: String,
}
/// One immediate child of a memory "directory", as derived by
/// [`immediate_children`]: either a note (`is_dir: false`, carrying its own
/// metadata) or a synthetic folder standing for a deeper path segment.
///
/// A folder has no row of its own — the key space is flat — so its size is
/// unknowable and its `updated_at` is the newest of the notes underneath it,
/// which is the only timestamp that means anything to a reader.
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryChild {
pub name: String,
pub is_dir: bool,
pub byte_len: Option<i64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
} }
const SELECT: &str = "SELECT id, path, content, created_at, updated_at FROM memory_docs"; const SELECT: &str = "SELECT id, path, content, created_at, updated_at FROM memory_docs";
@@ -143,7 +161,9 @@ pub async fn list_with_metadata(pool: &SqlitePool, prefix: &str) -> Result<Vec<M
ELSE LENGTH(content) - LENGTH(REPLACE(content, char(10), '')) ELSE LENGTH(content) - LENGTH(REPLACE(content, char(10), ''))
+ CASE WHEN substr(content, -1, 1) = char(10) THEN 0 ELSE 1 END + CASE WHEN substr(content, -1, 1) = char(10) THEN 0 ELSE 1 END
END AS line_count, END AS line_count,
LENGTH(CAST(content AS BLOB)) AS byte_len LENGTH(CAST(content AS BLOB)) AS byte_len,
created_at,
updated_at
FROM memory_docs FROM memory_docs
WHERE path LIKE ? ESCAPE '\\' WHERE path LIKE ? ESCAPE '\\'
ORDER BY updated_at DESC", ORDER BY updated_at DESC",
@@ -154,6 +174,78 @@ pub async fn list_with_metadata(pool: &SqlitePool, prefix: &str) -> Result<Vec<M
Ok(rows) Ok(rows)
} }
/// Derive the **immediate children** of one memory directory from a flat
/// listing, so the note store can be browsed like a tree (the file explorer's
/// `user-memory/` and `shared-memory/` roots).
///
/// The key space has no directories: `notes/2026/trip.md` is one row, and the
/// two folders above it exist only as segments of that key. So a level is read
/// by listing a prefix and cutting each remainder at the first `/` — a
/// remainder with no separator is a note at this level, one with a separator
/// contributes a synthetic folder, deduplicated by name.
///
/// `prefix` is the directory's key, `""` for the store root and otherwise
/// **slash-terminated**. Rows outside it are ignored rather than trusted, which
/// is what lets the caller query the looser unslashed prefix (`notes`) and use
/// the same rows both to spot an exact note — a "not a directory" — and to list
/// `notes/`, without a second round-trip. It matches `list_with_metadata`'s
/// `LIKE`, whose one query would otherwise have to become two.
///
/// Pure: no pool, no I/O. Order is dirs first, then name case-insensitively,
/// mirroring the on-disk listing the explorer shows beside it.
pub fn immediate_children(prefix: &str, rows: &[MemoryEntryMeta]) -> Vec<MemoryChild> {
let mut out: Vec<MemoryChild> = Vec::new();
let mut dirs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for row in rows {
let Some(rel) = row.path.strip_prefix(prefix) else { continue };
if rel.is_empty() {
continue; // the directory's own key, if a note happens to hold it
}
match rel.split_once('/') {
None => out.push(MemoryChild {
name: rel.to_string(),
is_dir: false,
byte_len: Some(row.byte_len.max(0)),
created_at: Some(row.created_at.clone()),
updated_at: Some(row.updated_at.clone()),
}),
Some((head, _)) => {
if head.is_empty() {
continue; // a `//` in the key: no folder to name
}
match dirs.get(head) {
Some(&i) => {
// Newest note underneath wins — the timestamps are
// SQLite `datetime('now')`, so lexical order is time order.
let slot = &mut out[i].updated_at;
if slot.as_deref().is_none_or(|cur| cur < row.updated_at.as_str()) {
*slot = Some(row.updated_at.clone());
}
}
None => {
dirs.insert(head.to_string(), out.len());
out.push(MemoryChild {
name: head.to_string(),
is_dir: true,
byte_len: None,
created_at: None,
updated_at: Some(row.updated_at.clone()),
});
}
}
}
}
}
out.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
out
}
/// Full-text search over note bodies and paths, best match first. `query` is /// Full-text search over note bodies and paths, best match first. `query` is
/// FTS5 MATCH syntax; `snippet` is a short excerpt of the body with the matched /// FTS5 MATCH syntax; `snippet` is a short excerpt of the body with the matched
/// terms wrapped in `[` … `]`. /// terms wrapped in `[` … `]`.
@@ -306,6 +398,79 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
fn meta(path: &str, updated_at: &str) -> MemoryEntryMeta {
MemoryEntryMeta {
path: path.to_string(),
line_count: 1,
byte_len: path.len() as i64,
created_at: "2026-01-01 00:00:00".to_string(),
updated_at: updated_at.to_string(),
}
}
#[test]
fn immediate_children_cuts_one_level_and_folds_folders() {
let rows = vec![
meta("notes/spesa.md", "2026-08-01 10:00:00"),
meta("notes/2026/trip.md", "2026-08-03 10:00:00"),
meta("notes/2026/hotel.md", "2026-08-09 10:00:00"),
meta("notes/2025/old.md", "2026-01-05 10:00:00"),
// Outside the directory: a sibling the looser `LIKE 'notes%'` also
// matches, and a note higher up.
meta("notesomething.md", "2026-08-02 10:00:00"),
meta("index.md", "2026-08-02 10:00:00"),
];
let kids = immediate_children("notes/", &rows);
let names: Vec<&str> = kids.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["2025", "2026", "spesa.md"], "dirs first, then name");
let y2026 = &kids[1];
assert!(y2026.is_dir);
assert_eq!(y2026.byte_len, None, "a synthetic folder has no size");
assert_eq!(
y2026.updated_at.as_deref(),
Some("2026-08-09 10:00:00"),
"a folder carries the newest note underneath it"
);
let note = &kids[2];
assert!(!note.is_dir);
assert_eq!(note.byte_len, Some("notes/spesa.md".len() as i64));
assert_eq!(note.updated_at.as_deref(), Some("2026-08-01 10:00:00"));
// Root level: the two top-level names, each once.
let root_kids = immediate_children("", &rows);
let root: Vec<&str> = root_kids.iter().map(|c| c.name.as_str()).collect();
assert_eq!(root, ["notes", "index.md", "notesomething.md"]);
assert!(immediate_children("empty/", &rows).is_empty(), "an unknown prefix is an empty dir");
}
/// The listing a directory view is built on must not read a caller-supplied
/// `%` or `_` as a wildcard: a note named `50%.md` is its own subtree, not a
/// window onto everyone else's.
#[tokio::test]
async fn list_with_metadata_escapes_like_wildcards() {
let (pool, dir) = owner_pool("like-escape").await;
upsert(&pool, "50%/a.md", "x").await.unwrap();
upsert(&pool, "50x/b.md", "y").await.unwrap();
upsert(&pool, "a_b/c.md", "z").await.unwrap();
upsert(&pool, "axb/d.md", "w").await.unwrap();
let pct: Vec<String> = list_with_metadata(&pool, "50%/").await.unwrap()
.into_iter().map(|e| e.path).collect();
assert_eq!(pct, ["50%/a.md"], "`%` matches itself, not any string");
let underscore: Vec<String> = list_with_metadata(&pool, "a_b/").await.unwrap()
.into_iter().map(|e| e.path).collect();
assert_eq!(underscore, ["a_b/c.md"], "`_` matches itself, not any character");
pool.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test] #[tokio::test]
async fn list_by_prefix_and_delete_deindexes() { async fn list_by_prefix_and_delete_deindexes() {
let (pool, dir) = owner_pool("list").await; let (pool, dir) = owner_pool("list").await;
+58
View File
@@ -0,0 +1,58 @@
# Files
The **Files** page (sidebar → Files) is where a member sees their own space: everything they can reach, in one list. Before it existed, files were reachable only through you — a user could ask "what's in the recipes folder?" but could not go and look. Now they can, and this document exists so you know what they are looking at when they mention it.
## What they see first
The page opens on a list of **places**, not files. This is deliberate: the things a person can reach are not folders inside one another, they are separate roots, and the list is the only level where they all appear together.
| Place | Path | Notes |
| --- | --- | --- |
| Home | `~` | Their own workspace. Always writable. |
| Personal memory | `user-memory/` | Their private notes — the ones you keep for them |
| Shared memory | `shared-memory/` | The group's common notes |
| Shared folders | `shared/{name}` | One entry per folder they are a member of |
| Projects | `projects/{owner}/{name}` | One entry per project they can reach |
| Skills | `skills/` | Installed skill folders. Read-only |
| Documentation | `docs/` | This documentation. Read-only |
A place they have no access to simply is not in the list — there is nothing to explain away.
**The paths shown are the paths you use.** Under each name the page prints the real path (`shared/recipes`, `projects/anna/holiday`), which is the same string you would pass to a file tool. That is worth pointing out to a user who asks how to tell you where to look: they can read the path off the page and say "the file in `shared/recipes/dolci`", and you will find it.
## Inside a place
Clicking a place opens a file explorer: one folder at a time, folders first, with size and dates. From there:
- **Clicking a file opens it** in the usual file viewer — Markdown rendered, images, PDFs, colored code, plain text.
- **Clicking a folder goes into it**; the breadcrumb at the top walks back out, and the browser's back button works too. The address bar carries the folder, so a user can bookmark or paste a link to exactly where they are.
- **The listing is live.** A file you create from a conversation, or another member uploads, appears within a second without a refresh. If a user says "it's not there", ask them to check the folder rather than assuming the write failed — but the page updates on its own, so a truly missing file is missing.
- **ZIP download** — the button in the toolbar downloads the whole open folder as a single archive. Useful when someone wants "all the photos" rather than one file.
## What they can change, and where
Write buttons — new folder, upload (including drag & drop), rename, delete — appear **only where that person may write**. Everywhere else the page shows a **Read-only** badge and no buttons.
Read-only for everyone: **Skills**, **Documentation**, and both memory stores. Read-only for some people: a shared folder or project where they were given read access only.
This matters when a user asks you to do something they just failed to do in the page: if the badge said read-only, you cannot do it either — the same rule applies to your file tools, and asking you is not a way around it. Tell them who to ask (an admin for a shared folder, the owner for a project).
## Memory in the page
The two memory stores appear as ordinary folders, and this is the first place a person can read their notes themselves rather than asking you. Two things to explain if it comes up:
- **They are read-only here.** A user can open and read a note, but cannot edit or delete it from the page. Changing memory goes through you, in conversation — which is what keeps the history in `log.md` honest and, for shared memory, what keeps the confirmation step in place.
- **The folders are not on disk.** Notes live in the database, not as files, so they will not appear if someone goes looking in a terminal. The page shows them as folders because that is the useful way to read them, not because they are files.
## What is not there
- **Places outside their space.** Only what is in the list. A path inside the sandbox but not under any of those roots (`/tmp/…`, for example) is not browsable from the page, though you can still read it with your own tools.
- **Search.** There is no search box yet. Finding something by content is still a question for you — you can search notes and files far better than a folder-by-folder look.
- **The phone app.** The Files page is desktop-only for now.
## Related
- [shared-folders.md](shared-folders.md) — who may see a shared folder, and read vs write access
- [projects.md](projects.md) — projects have their own explorer on the project page, the same one this page uses
- [memory.md](memory.md) — what goes in each memory store, and why shared memory asks for confirmation
- [skills.md](skills.md) — what the skill folders hold and why they can only be changed by installing
+2 -1
View File
@@ -4,7 +4,7 @@ This folder is written for **you, the assistant**, not for the human directly. I
Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance. Keep answers grounded in what's actually enabled and configured for this instance — check with the relevant tool (e.g. list installed/enabled plugins) rather than assuming everything described here is turned on. A feature documented here may not be enabled on this particular instance.
This index will grow over time. Right now it covers the interface, agents, memory, projects, shared folders, background tasks, system agents, access grants, connectors, skills, the sandbox, voice input and plugins; more sections (security groups…) will be added later. This index will grow over time. Right now it covers the interface, files, agents, memory, projects, shared folders, background tasks, system agents, access grants, connectors, skills, the sandbox, voice input and plugins; more sections (security groups…) will be added later.
## Features ## Features
@@ -12,6 +12,7 @@ This index will grow over time. Right now it covers the interface, agents, memor
| --- | --- | | --- | --- |
| [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request | | [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request |
| [agents.md](agents.md) | Agents: the three kinds (chat, task, system), which one you are talking to and why, the specialist agents the assistant delegates to, how the model is chosen, and adding a custom agent | | [agents.md](agents.md) | Agents: the three kinds (chat, task, system), which one you are talking to and why, the specialist agents the assistant delegates to, how the model is chosen, and adding a custom agent |
| [files.md](files.md) | The Files page: everywhere a member can reach — home, both memory stores, shared folders, projects, skills and docs — what they can change there, and what is deliberately read-only |
| [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing | | [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing |
| [shared-folders.md](shared-folders.md) | Shared folders: admin-managed folders with no chat of their own — who sees them, read vs write access, why the assistant asks before touching them, and when to choose a project instead | | [shared-folders.md](shared-folders.md) | Shared folders: admin-managed folders with no chat of their own — who sees them, read vs write access, why the assistant asks before touching them, and when to choose a project instead |
| [system-agents.md](system-agents.md) | Background agents that run on a schedule (event triage, the two memory lints, the nightly conversation review of a supervised account): what they watch, why they only ever report, why a run can be skipped, and their settings | | [system-agents.md](system-agents.md) | Background agents that run on a schedule (event triage, the two memory lints, the nightly conversation review of a supervised account): what they watch, why they only ever report, why a run can be skipped, and their settings |
+2
View File
@@ -19,6 +19,8 @@ The rule that decides between them, and the one to explain when a user asks: **s
**Writing to shared memory asks for confirmation.** Saving to their private memory is silent; adding or changing something in shared memory shows an approval card first, because it becomes visible to everyone. Appending to the shared `log.md` is the one exception — the history must always be recorded. **Writing to shared memory asks for confirmation.** Saving to their private memory is silent; adding or changing something in shared memory shows an approval card first, because it becomes visible to everyone. Appending to the shared `log.md` is the one exception — the history must always be recorded.
**They can read their notes themselves.** Both stores appear as folders on the **Files** page (sidebar → Files), so a user can open and read any note without asking you — but **read-only**: editing and deleting still go through you, which is what keeps `log.md` honest and, for shared memory, keeps the confirmation step in place. See [files.md](files.md).
**Superseded facts stay visible.** In shared memory nothing is deleted; an outdated fact is struck through and the new one added underneath. A user asking "why is the old date still there?" is seeing this on purpose. **Superseded facts stay visible.** In shared memory nothing is deleted; an outdated fact is struck through and the new one added underneath. A user asking "why is the old date still there?" is seeing this on purpose.
**You may decline to change a shared fact.** Every shared fact records who put it there. If someone tells you a fact is wrong and it isn't theirs, you note their claim — marked `unconfirmed` — but leave the fact alone until the person it belongs to, or an admin, confirms it. Explain it as protection, not distrust: it means nobody can quietly rewrite what the group relies on, and it means a mistake or a joke can be undone. **You may decline to change a shared fact.** Every shared fact records who put it there. If someone tells you a fact is wrong and it isn't theirs, you note their claim — marked `unconfirmed` — but leave the fact alone until the person it belongs to, or an admin, confirms it. Explain it as protection, not distrust: it means nobody can quietly rewrite what the group relies on, and it means a mistake or a joke can be undone.
+5 -5
View File
@@ -9,7 +9,7 @@ Shared folders are **managed by an admin**: an admin creates them, decides who i
Two things shared folders are *not*, so expectations stay right: Two things shared folders are *not*, so expectations stay right:
- They have **no chat of their own** — the files are shared, not a conversation. (That is what Projects are for; see [Shared folders vs Projects](#shared-folders-vs-projects) below.) - They have **no chat of their own** — the files are shared, not a conversation. (That is what Projects are for; see [Shared folders vs Projects](#shared-folders-vs-projects) below.)
- They have **no file explorer page** in the web app. Members work with the files through the assistant, and open individual files in the file viewer when the assistant shows them (see [Working with the files](#working-with-the-files)). - They have **no page of their own** in the web app. Members reach the files either through the assistant or from the general **Files** page, which lists every folder they belong to (see [Working with the files](#working-with-the-files)) — but there is no place where a folder is a thing with its own tabs, the way a project is.
## Creating a folder (admin) ## Creating a folder (admin)
@@ -35,12 +35,12 @@ Two things worth knowing about membership:
## Working with the files ## Working with the files
There is no file explorer for shared folders — no grid of files, no upload button. The files live on the server, and members reach them through the assistant: There are two ways in, and they work on the same files:
- **Ask the assistant** — "what's in the recipes folder?", "add this note to documents", "send me the manual for the boiler". The assistant knows which folders you belong to, can list their contents, open and search files, and — if you have read & write — create and edit them. - **Ask the assistant** — "what's in the recipes folder?", "add this note to documents", "send me the manual for the boiler". The assistant knows which folders you belong to, can list their contents, open and search files, and — if you have read & write — create and edit them.
- **Open a file** — when the assistant shows you a file from a shared folder, it opens in the usual file viewer (Markdown rendered, images, PDFs, syntax-colored code, text), exactly like any other file. You can read it there; editing in the viewer is available if you have read & write access. - **Open the Files page** (sidebar → Files) — every shared folder you belong to is one entry in the list. From there you can browse it, open a file in the viewer, download it, download the whole folder as a ZIP, and — only if you have read & write — upload, rename and delete. See [files.md](files.md).
A practical consequence: if a member wants a file *from* a shared folder, the assistant is the way to get it — there is no download button on the folder itself. (An admin can of course reach the folder directly on the server, but members should not need to.) Searching by content is still the assistant's job: the Files page browses, it does not search.
## What the assistant knows ## What the assistant knows
@@ -75,7 +75,7 @@ The two features look similar — a shared place for files with per-member acces
|---|---|---| |---|---|---|
| Who manages it | An admin (no owner; anyone can be removed) | The owner (a member) and read & write members | | Who manages it | An admin (no owner; anyone can be removed) | The owner (a member) and read & write members |
| Where it lives in the chat | No chat of its own | Its own conversation with the assistant (`project-{id}`), plus extra tabs | | Where it lives in the chat | No chat of its own | Its own conversation with the assistant (`project-{id}`), plus extra tabs |
| Files | No explorer page; work through the assistant | A live file explorer with upload, rename, delete, ZIP download | | Files | Browsable from the general Files page | The same explorer, on the project's own page |
| Assistant's access | Every read/write asks for confirmation | Reads and writes are frictionless (only the folder's membership limits them) | | Assistant's access | Every read/write asks for confirmation | Reads and writes are frictionless (only the folder's membership limits them) |
| Typical use | A place to *keep* shared documents | A place to *work together* on something | | Typical use | A place to *keep* shared documents | A place to *work together* on something |
+164 -3
View File
@@ -44,6 +44,45 @@ pub struct DirEntry {
pub modified_at: Option<String>, pub modified_at: Option<String>,
} }
/// A directory listing plus the two things the caller cannot work out on its own:
/// the agent path it actually landed on (the request may spell it `~/x`, `./x` or
/// `/root/x`) and whether this branch is writable.
///
/// `can_write` is here rather than a property the client is handed once, because
/// the answer changes per branch: the home is always writable, a shared folder or
/// project follows the membership's flag, `skills/` and `docs/` never are. It
/// comes from the same [`UserFs::can_write_to`] that `require_write` rejects with,
/// so the buttons an explorer offers and the writes the server accepts cannot
/// disagree.
#[derive(Serialize)]
pub struct DirListing {
pub path: String,
pub can_write: bool,
pub entries: Vec<DirEntry>,
}
/// One top-level root of the caller's namespace, as shown by the file explorer's
/// virtual root (blueprint `dir-explorer.md`).
///
/// The explorer reads **host-side**, while `shared/`, `projects/`, `skills/` and
/// `docs/` are bind mounts that live inside the container — so they are *not*
/// subdirectories of the host home, and an explorer anchored at `~` would show
/// less than the user has, with no way to reach the rest. This endpoint is that
/// missing level: from here on, navigation is the ordinary `/api/files/dir`.
///
/// `kind` is the discriminator the UI labels and picks an icon from; `name` is
/// meaningful only for the two kinds there can be several of (`shared`,
/// `project`), and `owner` only for a project, whose agent path is namespaced by
/// the owner's username.
#[derive(Serialize)]
pub struct FsRoot {
pub kind: &'static str,
pub path: String,
pub name: Option<String>,
pub owner: Option<String>,
pub can_write: bool,
}
fn fmt_ts(t: std::time::SystemTime) -> String { fn fmt_ts(t: std::time::SystemTime) -> String {
chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339() chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339()
} }
@@ -76,15 +115,83 @@ fn disk_etag(md: &std::fs::Metadata) -> String {
format!("\"{}-{}\"", mtime_ns, md.len()) format!("\"{}-{}\"", mtime_ns, md.len())
} }
/// GET /api/files/roots — the top-level roots of the caller's namespace, in the
/// order an explorer should show them: home, the two memory stores, the shared
/// folders they belong to, the projects they can reach, then the two read-only
/// trees. Derived entirely from the caller's `UserFs` snapshot (plus the memory
/// roots, which are virtual and so never appear in it), never from the request.
pub async fn list_roots(
State(state): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<FsRoot>>, ApiError> {
let ctx = require_context(&state, &auth.user_id).await?;
let fs = ctx.fs.load();
let simple = |kind, path: &str, can_write| FsRoot {
kind,
path: path.to_string(),
name: None,
owner: None,
can_write,
};
let mut roots = vec![
simple("home", "~", true),
// Read-only for now: the writers below route through `resolve_view_path`,
// which refuses memory paths, and `shared-memory/*` is `@fs_write require`
// for the agent — handing a user a button that walks past that rule is a
// decision of its own (see blueprint `dir-explorer.md`, task 5).
simple("user-memory", fs_tools::USER_MEMORY_ROOT, false),
simple("shared-memory", fs_tools::SHARED_MEMORY_ROOT, false),
];
for m in &fs.shared {
roots.push(FsRoot {
kind: "shared",
path: format!("shared/{}", m.name),
name: Some(m.name.clone()),
owner: None,
can_write: m.can_write,
});
}
for m in &fs.projects {
roots.push(FsRoot {
kind: "project",
path: format!("projects/{}/{}", m.owner_username, m.slug),
name: Some(m.slug.clone()),
owner: Some(m.owner_username.clone()),
can_write: m.can_write,
});
}
if fs.skills.is_some() {
roots.push(simple("skills", core_api::user_fs::SKILLS_ROOT, false));
}
if fs.docs_host.is_some() {
roots.push(simple("docs", "docs", false));
}
Ok(Json(roots))
}
/// GET /api/files/dir?path=… — the immediate children of a directory (dirs /// GET /api/files/dir?path=… — the immediate children of a directory (dirs
/// first, then name), resolved and scoped exactly like `GET /api/file`. /// first, then name), resolved and scoped exactly like `GET /api/file`.
///
/// A path under a virtual memory root is listed from `memory_docs` instead of
/// the disk — classified **before** `resolve_view_path`, which refuses memory
/// paths, in the same order [`get_file`] uses. See [`list_memory_dir`].
pub async fn list_dir( pub async fn list_dir(
State(state): State<Arc<Skald>>, State(state): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>, Extension(auth): Extension<AuthUser>,
Query(q): Query<FileQuery>, Query(q): Query<FileQuery>,
) -> Result<Json<Vec<DirEntry>>, ApiError> { ) -> Result<Json<DirListing>, ApiError> {
let ctx = require_context(&state, &auth.user_id).await?; let ctx = require_context(&state, &auth.user_id).await?;
let (abs, agent) = fs_tools::resolve_view_path(ctx.fs.load().as_ref(), &q.path) if let Some(mem) = fs_tools::classify_memory(&q.path) {
let (pool, root) = match mem.scope {
fs_tools::MemScope::User => (Arc::clone(&ctx.pool), fs_tools::USER_MEMORY_ROOT),
fs_tools::MemScope::Shared => (state.db().clone(), fs_tools::SHARED_MEMORY_ROOT),
};
return list_memory_dir(&pool, root, &mem.rel).await.map(Json);
}
let fs = ctx.fs.load();
let (abs, agent) = fs_tools::resolve_view_path(fs.as_ref(), &q.path)
.map_err(|e| ApiError::bad_request(e.to_string()))?; .map_err(|e| ApiError::bad_request(e.to_string()))?;
if !abs.is_dir() { if !abs.is_dir() {
return Err(ApiError::bad_request(format!("not a directory: {agent}"))); return Err(ApiError::bad_request(format!("not a directory: {agent}")));
@@ -108,7 +215,61 @@ pub async fn list_dir(
b.is_dir.cmp(&a.is_dir) b.is_dir.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
}); });
Ok(Json(entries)) Ok(Json(DirListing {
can_write: fs.can_write_to(&agent),
path: agent,
entries,
}))
}
/// The memory half of [`list_dir`]: one level of a note store, as a directory.
///
/// `rel` is the note key of the directory (`""` for the store root), already
/// clamped inside the store by [`fs_tools::classify_memory`]. The notes are
/// listed once with the **unslashed** prefix, which serves both answers this
/// handler owes: a row whose key is exactly `rel` means the caller asked for a
/// note, and the rest fold into one level by
/// [`memory_docs::immediate_children`]. `LIKE` escaping lives in the accessor,
/// so a note named `50%` lists its own subtree and nobody else's.
///
/// `can_write: false` is scope, not a property of the store (blueprint
/// `dir-explorer.md`, task 5): every writer here routes through
/// `resolve_view_path`, which refuses memory paths, and `shared-memory/*` is
/// `@fs_write require` for the agent — a button that walks past that rule is a
/// decision of its own.
async fn list_memory_dir(
pool: &sqlx::SqlitePool,
root: &str,
rel: &str,
) -> Result<DirListing, ApiError> {
let rel = rel.trim_end_matches('/');
let agent = if rel.is_empty() { root.to_string() } else { format!("{root}/{rel}") };
let rows = memory_docs::list_with_metadata(pool, rel).await?;
if rows.iter().any(|r| r.path == rel) {
return Err(ApiError::bad_request(format!("not a directory: {agent}")));
}
let prefix = if rel.is_empty() { String::new() } else { format!("{rel}/") };
let entries = memory_docs::immediate_children(&prefix, &rows)
.into_iter()
.map(|c| DirEntry {
path: format!("{agent}/{}", c.name),
name: c.name,
is_dir: c.is_dir,
size: c.byte_len.map(|b| b as u64),
created_at: c.created_at.as_deref().and_then(db_ts_to_rfc3339),
modified_at: c.updated_at.as_deref().and_then(db_ts_to_rfc3339),
})
.collect();
Ok(DirListing { path: agent, can_write: false, entries })
}
/// A SQLite `datetime('now')` stamp (`Y-m-d H:M:S`, UTC, no offset) as RFC 3339,
/// the shape [`DirEntry`] promises. Without the conversion a browser reads the
/// bare string as *local* time and every note is off by the viewer's own offset.
fn db_ts_to_rfc3339(raw: &str) -> Option<String> {
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S")
.ok()
.map(|n| n.and_utc().to_rfc3339())
} }
// ── Directory download (streaming ZIP) ───────────────────────────────────── // ── Directory download (streaming ZIP) ─────────────────────────────────────
+1
View File
@@ -233,6 +233,7 @@ pub fn router() -> Router<Arc<Skald>> {
// Files // Files
.route("/files", get(files::list_files)) .route("/files", get(files::list_files))
.route("/files/dir", get(files::list_dir)) .route("/files/dir", get(files::list_dir))
.route("/files/roots", get(files::list_roots))
.route("/file", get(files::get_file)) .route("/file", get(files::get_file))
.route("/file", post(files::create_file)) .route("/file", post(files::create_file))
.route("/file/upload", post(files::upload_file) .route("/file/upload", post(files::upload_file)
+2
View File
@@ -29,6 +29,7 @@ import { LlmRequestDetail } from './components/llm-request-detail.js';
import { SessionDetailPage } from './components/session-detail.js'; import { SessionDetailPage } from './components/session-detail.js';
import { SystemAgentsPage } from './components/system-agents.js'; import { SystemAgentsPage } from './components/system-agents.js';
import { ProjectsPage } from './components/projects/index.js'; import { ProjectsPage } from './components/projects/index.js';
import { FilesPage } from './components/files-page.js';
import { FileViewerPage } from './components/file-viewer-page.js'; import { FileViewerPage } from './components/file-viewer-page.js';
import { ToolDetailPage } from './components/tool-detail-page.js'; import { ToolDetailPage } from './components/tool-detail-page.js';
import { SetupPage } from './components/setup-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('session-detail-page', SessionDetailPage);
customElements.define('system-agents-page', SystemAgentsPage); customElements.define('system-agents-page', SystemAgentsPage);
customElements.define('projects-page', ProjectsPage); customElements.define('projects-page', ProjectsPage);
customElements.define('files-page', FilesPage);
customElements.define('file-viewer-page', FileViewerPage); customElements.define('file-viewer-page', FileViewerPage);
customElements.define('tool-detail-page', ToolDetailPage); customElements.define('tool-detail-page', ToolDetailPage);
customElements.define('setup-page', SetupPage); customElements.define('setup-page', SetupPage);
+223
View File
@@ -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>
`;
}
}
+6 -8
View File
@@ -1,11 +1,11 @@
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js'; import { LightElement } from '../../lib/base.js';
import { t } from '../../lib/i18n.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 /// A project's detail page: header + description, then two tabs — **Files** (the
/// live explorer over the project folder, `<project-files-panel>`) and /// shared `<file-explorer>`, pointed at the project folder) and **Sharing**
/// **Sharing** (member picker with read/write, mirroring the shared-folders UI). /// (member picker with read/write, mirroring the shared-folders UI).
export class ProjectBoardSection extends LightElement { export class ProjectBoardSection extends LightElement {
static properties = { static properties = {
_project: { state: true }, _project: { state: true },
@@ -276,13 +276,11 @@ export class ProjectBoardSection extends LightElement {
${this._renderTabs()} ${this._renderTabs()}
<div class="p-3"> <div class="p-3">
<project-files-panel .project=${this._project} <file-explorer .root=${this._project.root_path ?? ''}
style=${this._tab === 'files' ? '' : 'display:none'}></project-files-panel> style=${this._tab === 'files' ? '' : 'display:none'}></file-explorer>
${this._tab === 'sharing' ? this._renderSharePanel() : nothing} ${this._tab === 'sharing' ? this._renderSharePanel() : nothing}
</div> </div>
</div> </div>
`; `;
} }
} }
customElements.define('project-files-panel', ProjectFilesPanel);
@@ -3,21 +3,45 @@ import { LightElement } from '../../lib/base.js';
import { t } from '../../lib/i18n.js'; import { t } from '../../lib/i18n.js';
import { fileWatcher } from '../../lib/file-watcher.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 /// 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`). /// 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` /// reloads in real time: the shared `/api/file/watch` socket (the `fileWatcher`
/// singleton) pushes a `changed` event for the open directory whenever another /// singleton) pushes a `changed` event for the open directory whenever someone
/// member — or the agent, from inside its container — creates/modifies/removes /// else — or the agent, from inside its container — creates/modifies/removes a
/// a file in it. Write actions (new folder, upload, rename, delete) are offered /// file in it.
/// only to members with `can_write` and are gated server-side too. ///
export class ProjectFilesPanel extends LightElement { /// **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 = { static properties = {
project: { attribute: false }, /// 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 }, _rel: { state: true },
_entries: { state: true }, _entries: { state: true },
_canWrite: { state: true },
_loading: { state: true }, _loading: { state: true },
_error: { state: true }, _error: { state: true },
_busy: { state: true }, _busy: { state: true },
@@ -27,9 +51,12 @@ export class ProjectFilesPanel extends LightElement {
constructor() { constructor() {
super(); super();
this.project = null; this.root = '';
this._rel = ''; // path relative to the project root ('' = root) this.rootLabel = '/';
this.rel = '';
this._rel = ''; // path relative to `root` ('' = the root itself)
this._entries = null; this._entries = null;
this._canWrite = false;
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this._busy = false; this._busy = false;
@@ -41,14 +68,13 @@ export class ProjectFilesPanel extends LightElement {
} }
willUpdate(changed) { willUpdate(changed) {
// (Re)open the root only when the project itself changes — a refetch of the if (!this.root) return;
// same project (member edits) must not reset the current folder. // Re-anchor only on a real move: a re-render with the same root must not
if (changed.has('project')) { // throw away the folder the user navigated to, and a `rel` echoing back the
const prev = changed.get('project'); // click that produced it is already where it says (see the class comment).
if (this.project?.root_path && this.project.root_path !== prev?.root_path) { const movedRoot = changed.has('root') && this.root !== changed.get('root');
this._open(''); const movedRel = changed.has('rel') && this.rel !== this._rel;
} if (movedRoot || movedRel) this._open(this.rel ?? '');
}
} }
disconnectedCallback() { disconnectedCallback() {
@@ -58,8 +84,7 @@ export class ProjectFilesPanel extends LightElement {
} }
_dirPath() { _dirPath() {
const root = this.project?.root_path ?? ''; return this._rel ? `${this.root}/${this._rel}` : this.root;
return this._rel ? `${root}/${this._rel}` : root;
} }
async _open(rel) { async _open(rel) {
@@ -81,12 +106,14 @@ export class ProjectFilesPanel extends LightElement {
} }
async _load() { async _load() {
if (!this.project?.root_path) return; if (!this.root) return;
this._loading = true; this._loading = true;
try { try {
const res = await fetch(`/api/files/dir?path=${encodeURIComponent(this._dirPath())}`); const res = await fetch(`/api/files/dir?path=${encodeURIComponent(this._dirPath())}`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
this._entries = await res.json(); const listing = await res.json();
this._entries = listing.entries;
this._canWrite = !!listing.can_write;
this._error = null; this._error = null;
} catch (e) { } catch (e) {
this._error = e.message; this._error = e.message;
@@ -97,32 +124,41 @@ export class ProjectFilesPanel extends LightElement {
// ── Navigation ──────────────────────────────────────────────────────────── // ── 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) { _enter(entry) {
if (entry.is_dir) { 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 { } else {
window.openFile(entry.path); window.openFile(entry.path);
} }
} }
_goTo(index) { _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('/') : []; 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 ───────────────────────────────────────────────────────── // ── Write actions ─────────────────────────────────────────────────────────
_openModal(mode, target = null) { _openModal(mode, target = null) {
this._modal = { mode, name: target?.name ?? '', target }; 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) { async _submitModal(e) {
e.preventDefault(); e.preventDefault();
const name = (this._modal?.name ?? '').trim(); const name = (this._modal?.name ?? '').trim();
if (!name || name.includes('/') || name.includes('\\')) { if (!name || name.includes('/') || name.includes('\\')) {
this._error = t('projects.files.error.name'); this._error = t('files.error.name');
return; return;
} }
this._busy = true; this._busy = true;
@@ -152,7 +188,7 @@ export class ProjectFilesPanel extends LightElement {
} }
async _remove(entry) { 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; if (!confirm(t(key, { name: entry.name }))) return;
this._busy = true; this._busy = true;
try { try {
@@ -189,7 +225,7 @@ export class ProjectFilesPanel extends LightElement {
} }
_pickFiles() { _pickFiles() {
this.querySelector('.pf-file-input')?.click(); this.querySelector('.fx-file-input')?.click();
} }
// ── Rendering ───────────────────────────────────────────────────────────── // ── Rendering ─────────────────────────────────────────────────────────────
@@ -202,9 +238,9 @@ export class ProjectFilesPanel extends LightElement {
<ol class="breadcrumb mb-0" style="font-size:0.9rem"> <ol class="breadcrumb mb-0" style="font-size:0.9rem">
<li class="breadcrumb-item ${segs.length === 0 ? 'active' : ''}"> <li class="breadcrumb-item ${segs.length === 0 ? 'active' : ''}">
${segs.length === 0 ${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); }} : 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> </li>
${segs.map((s, i) => html` ${segs.map((s, i) => html`
<li class="breadcrumb-item ${i === segs.length - 1 ? 'active' : ''}"> <li class="breadcrumb-item ${i === segs.length - 1 ? 'active' : ''}">
@@ -219,31 +255,30 @@ export class ProjectFilesPanel extends LightElement {
} }
_renderToolbar() { _renderToolbar() {
const canWrite = !!this.project?.can_write;
return html` return html`
<div class="d-flex align-items-center gap-2 mb-2"> <div class="d-flex align-items-center gap-2 mb-2">
${this._renderBreadcrumb()} ${this._renderBreadcrumb()}
<div class="ms-auto d-flex gap-1"> <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()}> ?disabled=${this._loading} @click=${() => this._load()}>
<i class="bi bi-arrow-clockwise"></i> <i class="bi bi-arrow-clockwise"></i>
</button> </button>
<a class="btn btn-sm btn-outline-secondary" download <a class="btn btn-sm btn-outline-secondary" download
href=${`/api/file/download?path=${encodeURIComponent(this._dirPath())}`}> 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> </a>
${canWrite ? html` ${this._canWrite ? html`
<button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy} <button class="btn btn-sm btn-outline-secondary" ?disabled=${this._busy}
@click=${() => this._openModal('mkdir')}> @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>
<button class="btn btn-sm btn-outline-primary" ?disabled=${this._busy} <button class="btn btn-sm btn-outline-primary" ?disabled=${this._busy}
@click=${() => this._pickFiles()}> @click=${() => this._pickFiles()}>
${this._busy ${this._busy
? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('projects.files.uploading')}` ? html`<span class="spinner-border spinner-border-sm me-1"></span>${t('files.uploading')}`
: html`<i class="bi bi-upload me-1"></i>${t('projects.files.btn.upload')}`} : html`<i class="bi bi-upload me-1"></i>${t('files.btn.upload')}`}
</button> </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 = ''; }} /> @change=${e => { this._uploadFiles([...e.target.files]); e.target.value = ''; }} />
` : nothing} ` : nothing}
</div> </div>
@@ -285,7 +320,6 @@ export class ProjectFilesPanel extends LightElement {
} }
_renderRow(entry) { _renderRow(entry) {
const canWrite = !!this.project?.can_write;
return html` return html`
<tr style="cursor:pointer" @click=${() => this._enter(entry)}> <tr style="cursor:pointer" @click=${() => this._enter(entry)}>
<td style="width:2rem"><i class="bi ${this._iconFor(entry)}"></i></td> <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-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()}> <td class="text-end text-nowrap" @click=${e => e.stopPropagation()}>
<a class="btn btn-sm btn-link text-secondary p-0 me-2" download <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 href=${entry.is_dir
? `/api/file/download?path=${encodeURIComponent(entry.path)}` ? `/api/file/download?path=${encodeURIComponent(entry.path)}`
: `/api/file?path=${encodeURIComponent(entry.path)}&force_download=true`}> : `/api/file?path=${encodeURIComponent(entry.path)}&force_download=true`}>
<i class="bi bi-download"></i> <i class="bi bi-download"></i>
</a> </a>
${canWrite ? html` ${this._canWrite ? html`
<button class="btn btn-sm btn-link text-secondary p-0 me-2" title=${t('projects.files.action.rename')} <button class="btn btn-sm btn-link text-secondary p-0 me-2" title=${t('files.action.rename')}
@click=${() => this._openModal('rename', entry)}> @click=${() => this._openModal('rename', entry)}>
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </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)}> ?disabled=${this._busy} @click=${() => this._remove(entry)}>
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
@@ -324,7 +358,7 @@ export class ProjectFilesPanel extends LightElement {
return html` return html`
<div class="text-center text-muted py-4"> <div class="text-center text-muted py-4">
<i class="bi bi-folder2-open" style="font-size:1.4rem"></i> <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> </div>
`; `;
} }
@@ -333,10 +367,10 @@ export class ProjectFilesPanel extends LightElement {
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th>${t('projects.files.col.name')}</th> <th>${t('files.col.name')}</th>
<th style="width:9.5rem">${t('projects.files.col.created')}</th> <th style="width:9.5rem">${t('files.col.created')}</th>
<th style="width:9.5rem">${t('projects.files.col.modified')}</th> <th style="width:9.5rem">${t('files.col.modified')}</th>
<th class="text-end" style="width:5.5rem">${t('projects.files.col.size')}</th> <th class="text-end" style="width:5.5rem">${t('files.col.size')}</th>
<th style="width:6rem"></th> <th style="width:6rem"></th>
</tr> </tr>
</thead> </thead>
@@ -357,7 +391,7 @@ export class ProjectFilesPanel extends LightElement {
<div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:1rem">
<i class="bi ${isMkdir ? 'bi-folder-plus' : 'bi-pencil'}"></i> <i class="bi ${isMkdir ? 'bi-folder-plus' : 'bi-pencil'}"></i>
<span style="font-weight:600"> <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> </span>
<button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem" <button type="button" style="margin-left:auto;border:none;background:none;cursor:pointer;font-size:1.1rem"
@click=${() => this._modal = null}> @click=${() => this._modal = null}>
@@ -366,16 +400,16 @@ export class ProjectFilesPanel extends LightElement {
</div> </div>
<form @submit=${e => this._submitModal(e)}> <form @submit=${e => this._submitModal(e)}>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold" style="font-size:0.82rem">${t('projects.files.modal.name')}</label> <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 pf-modal-input" required <input type="text" class="form-control form-control-sm fx-modal-input" required
.value=${this._modal.name} .value=${this._modal.name}
@input=${e => this._modal = { ...this._modal, name: e.target.value }} /> @input=${e => this._modal = { ...this._modal, name: e.target.value }} />
</div> </div>
<div style="display:flex;justify-content:flex-end;gap:0.5rem"> <div style="display:flex;justify-content:flex-end;gap:0.5rem">
<button type="button" class="btn btn-sm btn-outline-secondary" <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}> <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> </button>
</div> </div>
</form> </form>
@@ -385,13 +419,12 @@ export class ProjectFilesPanel extends LightElement {
} }
render() { render() {
if (!this.project?.root_path) return nothing; if (!this.root) return nothing;
const canWrite = !!this.project?.can_write;
return html` return html`
<div class="card ${this._drag ? 'border-primary' : ''}" <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} @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"> <div class="card-body">
${this._renderToolbar()} ${this._renderToolbar()}
${this._error ? html` ${this._error ? html`
@@ -399,7 +432,7 @@ export class ProjectFilesPanel extends LightElement {
` : nothing} ` : nothing}
${this._drag ? html` ${this._drag ? html`
<div class="text-center text-primary py-3" style="font-size:0.9rem"> <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> </div>
` : this._renderTable()} ` : this._renderTable()}
</div> </div>
@@ -408,3 +441,5 @@ export class ProjectFilesPanel extends LightElement {
`; `;
} }
} }
customElements.define('file-explorer', FileExplorer);
+5 -1
View File
@@ -23,6 +23,10 @@ const NAV = [
{ id: 'inbox', group: 'workspace', priority: 20, icon: 'inbox', labelKey: 'nav.inbox' }, { id: 'inbox', group: 'workspace', priority: 20, icon: 'inbox', labelKey: 'nav.inbox' },
{ id: 'dashboard', group: 'workspace', priority: 30, icon: 'speedometer2', labelKey: 'nav.dashboard' }, { id: 'dashboard', group: 'workspace', priority: 30, icon: 'speedometer2', labelKey: 'nav.dashboard' },
{ id: 'projects', group: 'workspace', priority: 40, icon: 'kanban', labelKey: 'nav.projects' }, { 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' }, { 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 // 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. // 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. // `connector` (singular) is the per-connector detail page, `connectors` the list.
// `plugin-catalog` is the pre-merge hash of what is now `#plugins`. // `plugin-catalog` is the pre-merge hash of what is now `#plugins`.
const page = segment === 'plugin-catalog' ? 'plugins' : segment; 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() { _tasksSectionFromHash() {
+1
View File
@@ -75,6 +75,7 @@ tool-detail-page {
users-page, users-page,
roles-page, roles-page,
shared-folders-page, shared-folders-page,
files-page,
connectors-page, connectors-page,
connector-detail-page, connector-detail-page,
plugin-catalog-page, plugin-catalog-page,
+36 -20
View File
@@ -8,6 +8,7 @@ export default {
'nav.inbox': 'Inbox', 'nav.inbox': 'Inbox',
'nav.dashboard': 'Dashboard', 'nav.dashboard': 'Dashboard',
'nav.projects': 'Projects', 'nav.projects': 'Projects',
'nav.files': 'Files',
'nav.tasks': 'Task Manager', 'nav.tasks': 'Task Manager',
'nav.tasks.running': 'Running Tasks', 'nav.tasks.running': 'Running Tasks',
'nav.tasks.cron': 'Cron Jobs', 'nav.tasks.cron': 'Cron Jobs',
@@ -266,26 +267,40 @@ export default {
'projects.share.access.readwrite':'Read & write', 'projects.share.access.readwrite':'Read & write',
'projects.tabs.files': 'Files', 'projects.tabs.files': 'Files',
'projects.tabs.sharing': 'Sharing', 'projects.tabs.sharing': 'Sharing',
'projects.files.col.name': 'Name',
'projects.files.col.created': 'Created', // ── File explorer (shared component: projects, Files section) ───────────────
'projects.files.col.modified': 'Modified', 'files.col.name': 'Name',
'projects.files.col.size': 'Size', 'files.col.created': 'Created',
'projects.files.empty': 'This folder is empty.', 'files.col.modified': 'Modified',
'projects.files.refresh': 'Refresh', 'files.col.size': 'Size',
'projects.files.drop': 'Drop files here to upload', 'files.empty': 'This folder is empty.',
'projects.files.btn.new_folder': 'New folder', 'files.refresh': 'Refresh',
'projects.files.btn.upload': 'Upload', 'files.drop': 'Drop files here to upload',
'projects.files.btn.download': 'Download ZIP', 'files.btn.new_folder': 'New folder',
'projects.files.uploading': 'Uploading…', 'files.btn.upload': 'Upload',
'projects.files.action.download': 'Download', 'files.btn.download': 'Download ZIP',
'projects.files.action.rename': 'Rename', 'files.uploading': 'Uploading…',
'projects.files.action.delete': 'Delete', 'files.action.download': 'Download',
'projects.files.confirm.delete_file': 'Delete "{name}"?', 'files.action.rename': 'Rename',
'projects.files.confirm.delete_dir': 'Delete the folder "{name}" and everything inside it?', 'files.action.delete': 'Delete',
'projects.files.modal.mkdir': 'New folder', 'files.confirm.delete_file': 'Delete "{name}"?',
'projects.files.modal.rename': 'Rename "{name}"', 'files.confirm.delete_dir': 'Delete the folder "{name}" and everything inside it?',
'projects.files.modal.name': 'Name', 'files.modal.mkdir': 'New folder',
'projects.files.error.name': 'Enter a valid name (no slashes).', '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 detail ──────────────────────────────────────────────────────────
'project_board.back': 'Projects', 'project_board.back': 'Projects',
@@ -1226,6 +1241,7 @@ export default {
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.loading': 'Loading…', 'common.loading': 'Loading…',
'common.saved': 'Saved', 'common.saved': 'Saved',
'common.create': 'Create',
// ── Shared Folders (blueprint §6) ──────────────────────────────────────────── // ── Shared Folders (blueprint §6) ────────────────────────────────────────────
'nav.shared_folders': 'Shared Folders', 'nav.shared_folders': 'Shared Folders',
+36 -20
View File
@@ -8,6 +8,7 @@ export default {
'nav.inbox': 'Boîte de réception', 'nav.inbox': 'Boîte de réception',
'nav.dashboard': 'Tableau de bord', 'nav.dashboard': 'Tableau de bord',
'nav.projects': 'Projets', 'nav.projects': 'Projets',
'nav.files': 'Fichiers',
'nav.tasks': 'Gestionnaire de tâches', 'nav.tasks': 'Gestionnaire de tâches',
'nav.tasks.running': 'Tâches en cours', 'nav.tasks.running': 'Tâches en cours',
'nav.tasks.cron': 'Tâches Cron', 'nav.tasks.cron': 'Tâches Cron',
@@ -266,26 +267,40 @@ export default {
'projects.share.access.readwrite':'Lecture et écriture', 'projects.share.access.readwrite':'Lecture et écriture',
'projects.tabs.files': 'Fichiers', 'projects.tabs.files': 'Fichiers',
'projects.tabs.sharing': 'Partage', 'projects.tabs.sharing': 'Partage',
'projects.files.col.name': 'Nom',
'projects.files.col.created': 'Création', // ── File explorer (shared component: projects, Files section) ───────────────
'projects.files.col.modified': 'Modification', 'files.col.name': 'Nom',
'projects.files.col.size': 'Taille', 'files.col.created': 'Création',
'projects.files.empty': 'Ce dossier est vide.', 'files.col.modified': 'Modification',
'projects.files.refresh': 'Actualiser', 'files.col.size': 'Taille',
'projects.files.drop': 'Déposez les fichiers ici pour les envoyer', 'files.empty': 'Ce dossier est vide.',
'projects.files.btn.new_folder': 'Nouveau dossier', 'files.refresh': 'Actualiser',
'projects.files.btn.upload': 'Envoyer', 'files.drop': 'Déposez les fichiers ici pour les envoyer',
'projects.files.btn.download': 'Télécharger (ZIP)', 'files.btn.new_folder': 'Nouveau dossier',
'projects.files.uploading': 'Envoi…', 'files.btn.upload': 'Envoyer',
'projects.files.action.download': 'Télécharger', 'files.btn.download': 'Télécharger (ZIP)',
'projects.files.action.rename': 'Renommer', 'files.uploading': 'Envoi…',
'projects.files.action.delete': 'Supprimer', 'files.action.download': 'Télécharger',
'projects.files.confirm.delete_file': 'Supprimer « {name} » ?', 'files.action.rename': 'Renommer',
'projects.files.confirm.delete_dir': 'Supprimer le dossier « {name} » et tout son contenu ?', 'files.action.delete': 'Supprimer',
'projects.files.modal.mkdir': 'Nouveau dossier', 'files.confirm.delete_file': 'Supprimer « {name} » ?',
'projects.files.modal.rename': 'Renommer « {name} »', 'files.confirm.delete_dir': 'Supprimer le dossier « {name} » et tout son contenu ?',
'projects.files.modal.name': 'Nom', 'files.modal.mkdir': 'Nouveau dossier',
'projects.files.error.name': 'Saisissez un nom valide (sans barres obliques).', '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 ──────────────────────────────────────────────────────── // ── Détail du projet ────────────────────────────────────────────────────────
'project_board.back': 'Projets', 'project_board.back': 'Projets',
@@ -1213,6 +1228,7 @@ export default {
'common.cancel': 'Annuler', 'common.cancel': 'Annuler',
'common.loading': 'Chargement…', 'common.loading': 'Chargement…',
'common.saved': 'Enregistré', 'common.saved': 'Enregistré',
'common.create': 'Créer',
// ── Shared Folders (blueprint §6) ──────────────────────────────────────────── // ── Shared Folders (blueprint §6) ────────────────────────────────────────────
'nav.shared_folders': 'Dossiers partagés', 'nav.shared_folders': 'Dossiers partagés',
+36 -20
View File
@@ -8,6 +8,7 @@ export default {
'nav.inbox': 'Richieste', 'nav.inbox': 'Richieste',
'nav.dashboard': 'Dashboard', 'nav.dashboard': 'Dashboard',
'nav.projects': 'Progetti', 'nav.projects': 'Progetti',
'nav.files': 'File',
'nav.tasks': 'Gestione attività', 'nav.tasks': 'Gestione attività',
'nav.tasks.running': 'Attività in corso', 'nav.tasks.running': 'Attività in corso',
'nav.tasks.cron': 'Attività ricorrenti', 'nav.tasks.cron': 'Attività ricorrenti',
@@ -290,26 +291,40 @@ export default {
'projects.share.access.readwrite':'Lettura e scrittura', 'projects.share.access.readwrite':'Lettura e scrittura',
'projects.tabs.files': 'File', 'projects.tabs.files': 'File',
'projects.tabs.sharing': 'Condivisione', 'projects.tabs.sharing': 'Condivisione',
'projects.files.col.name': 'Nome',
'projects.files.col.created': 'Creazione', // ── File explorer (shared component: projects, Files section) ───────────────
'projects.files.col.modified': 'Ultima modifica', 'files.col.name': 'Nome',
'projects.files.col.size': 'Dimensione', 'files.col.created': 'Creazione',
'projects.files.empty': 'Questa cartella è vuota.', 'files.col.modified': 'Ultima modifica',
'projects.files.refresh': 'Aggiorna', 'files.col.size': 'Dimensione',
'projects.files.drop': 'Trascina qui i file per caricarli', 'files.empty': 'Questa cartella è vuota.',
'projects.files.btn.new_folder': 'Nuova cartella', 'files.refresh': 'Aggiorna',
'projects.files.btn.upload': 'Carica', 'files.drop': 'Trascina qui i file per caricarli',
'projects.files.btn.download': 'Scarica ZIP', 'files.btn.new_folder': 'Nuova cartella',
'projects.files.uploading': 'Caricamento…', 'files.btn.upload': 'Carica',
'projects.files.action.download': 'Scarica', 'files.btn.download': 'Scarica ZIP',
'projects.files.action.rename': 'Rinomina', 'files.uploading': 'Caricamento…',
'projects.files.action.delete': 'Elimina', 'files.action.download': 'Scarica',
'projects.files.confirm.delete_file': 'Eliminare "{name}"?', 'files.action.rename': 'Rinomina',
'projects.files.confirm.delete_dir': 'Eliminare la cartella "{name}" e tutto il suo contenuto?', 'files.action.delete': 'Elimina',
'projects.files.modal.mkdir': 'Nuova cartella', 'files.confirm.delete_file': 'Eliminare "{name}"?',
'projects.files.modal.rename': 'Rinomina "{name}"', 'files.confirm.delete_dir': 'Eliminare la cartella "{name}" e tutto il suo contenuto?',
'projects.files.modal.name': 'Nome', 'files.modal.mkdir': 'Nuova cartella',
'projects.files.error.name': 'Inserisci un nome valido (senza barre).', '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 ────────────────────────────────────────────────────── // ── Dettaglio progetto ──────────────────────────────────────────────────────
'project_board.back': 'Progetti', 'project_board.back': 'Progetti',
@@ -1213,6 +1228,7 @@ export default {
'common.cancel': 'Annulla', 'common.cancel': 'Annulla',
'common.loading': 'Caricamento…', 'common.loading': 'Caricamento…',
'common.saved': 'Salvato', 'common.saved': 'Salvato',
'common.create': 'Crea',
// ── Cartelle condivise (blueprint §6) ──────────────────────────────────────── // ── Cartelle condivise (blueprint §6) ────────────────────────────────────────
'nav.shared_folders': 'Cartelle condivise', 'nav.shared_folders': 'Cartelle condivise',
+1
View File
@@ -120,6 +120,7 @@
<session-detail-page style="display:none"></session-detail-page> <session-detail-page style="display:none"></session-detail-page>
<system-agents-page style="display:none"></system-agents-page> <system-agents-page style="display:none"></system-agents-page>
<projects-page style="display:none"></projects-page> <projects-page style="display:none"></projects-page>
<files-page style="display:none"></files-page>
<file-viewer-page style="display:none"></file-viewer-page> <file-viewer-page style="display:none"></file-viewer-page>
<tool-detail-page style="display:none"></tool-detail-page> <tool-detail-page style="display:none"></tool-detail-page>
<app-copilot></app-copilot> <app-copilot></app-copilot>