From 488c702517b88feae27ed89690177a101c9f1a89 Mon Sep 17 00:00:00 2001 From: Daniele Date: Sat, 22 Aug 2026 20:09:56 +0100 Subject: [PATCH] feat(files): a Files section over the caller's whole space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 + CLAUDE.md | 20 +- crates/skald-core/src/db/memory_docs.rs | 167 ++++++++++++- docs/files.md | 58 +++++ docs/index.md | 3 +- docs/memory.md | 2 + docs/shared-folders.md | 10 +- src/frontend/api/files.rs | 167 ++++++++++++- src/frontend/api/mod.rs | 1 + web/app.js | 2 + web/components/files-page.js | 223 ++++++++++++++++++ web/components/projects/project-board.js | 14 +- .../file-explorer.js} | 169 +++++++------ web/components/sidebar.js | 6 +- web/css/page-shell.css | 1 + web/i18n/en.js | 56 +++-- web/i18n/fr.js | 56 +++-- web/i18n/it.js | 56 +++-- web/index.html | 1 + 19 files changed, 870 insertions(+), 148 deletions(-) create mode 100644 docs/files.md create mode 100644 web/components/files-page.js rename web/components/{projects/project-files.js => shared/file-explorer.js} (69%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5eb2c8..7dabf76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ release PR may merge — and a section is closed at the commit that bumps it. ### 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 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 diff --git a/CLAUDE.md b/CLAUDE.md index eb08023..44f4393 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. -**UI** (`web/components/projects/`): `index.js` (`` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`` — 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` (`` — the explorer). The mobile app has its own read-only `shared/projects-page.js` (list → open project chat). +**UI** (`web/components/projects/`): `index.js` (`` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`` — 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 `` 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`, ``): **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 `` (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) @@ -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-folders.js` | `` | `#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` — 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` — the caller's whole space. Level 0 is the **virtual root** (`GET /api/files/roots`), level 1 the shared ``. See the Files section | +| `shared/file-explorer.js` | `` | 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` | `` | 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 | | `llm-providers.js` | `` | LLM provider management | diff --git a/crates/skald-core/src/db/memory_docs.rs b/crates/skald-core/src/db/memory_docs.rs index 5430d7e..bc9c923 100644 --- a/crates/skald-core/src/db/memory_docs.rs +++ b/crates/skald-core/src/db/memory_docs.rs @@ -42,6 +42,24 @@ pub struct MemoryEntryMeta { pub path: String, pub line_count: 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, + pub created_at: Option, + pub updated_at: Option, } 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 Result Vec { + let mut out: Vec = Vec::new(); + let mut dirs: std::collections::HashMap = 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 /// FTS5 MATCH syntax; `snippet` is a short excerpt of the body with the matched /// terms wrapped in `[` … `]`. @@ -306,6 +398,79 @@ mod tests { 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 = 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 = 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] async fn list_by_prefix_and_delete_deindexes() { let (pool, dir) = owner_pool("list").await; diff --git a/docs/files.md b/docs/files.md new file mode 100644 index 0000000..b98d917 --- /dev/null +++ b/docs/files.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 8e2e23f..59ef86c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. -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 @@ -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 | | [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 | | [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 | diff --git a/docs/memory.md b/docs/memory.md index de794b1..19018bd 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -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. +**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. **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. diff --git a/docs/shared-folders.md b/docs/shared-folders.md index c247799..2110129 100644 --- a/docs/shared-folders.md +++ b/docs/shared-folders.md @@ -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: - 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) @@ -35,12 +35,12 @@ Two things worth knowing about membership: ## 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. -- **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 @@ -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 | | 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) | | Typical use | A place to *keep* shared documents | A place to *work together* on something | diff --git a/src/frontend/api/files.rs b/src/frontend/api/files.rs index 74223ec..e21ab9c 100644 --- a/src/frontend/api/files.rs +++ b/src/frontend/api/files.rs @@ -44,6 +44,45 @@ pub struct DirEntry { pub modified_at: Option, } +/// 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, +} + +/// 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, + pub owner: Option, + pub can_write: bool, +} + fn fmt_ts(t: std::time::SystemTime) -> String { chrono::DateTime::::from(t).to_rfc3339() } @@ -76,15 +115,83 @@ fn disk_etag(md: &std::fs::Metadata) -> String { 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>, + Extension(auth): Extension, +) -> Result>, 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 /// 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( State(state): State>, Extension(auth): Extension, Query(q): Query, -) -> Result>, ApiError> { +) -> Result, ApiError> { 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()))?; if !abs.is_dir() { 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) .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 { + 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 { + chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S") + .ok() + .map(|n| n.and_utc().to_rfc3339()) } // ── Directory download (streaming ZIP) ───────────────────────────────────── diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index 01e7a66..acae913 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -233,6 +233,7 @@ pub fn router() -> Router> { // Files .route("/files", get(files::list_files)) .route("/files/dir", get(files::list_dir)) + .route("/files/roots", get(files::list_roots)) .route("/file", get(files::get_file)) .route("/file", post(files::create_file)) .route("/file/upload", post(files::upload_file) diff --git a/web/app.js b/web/app.js index e280496..f28b57b 100644 --- a/web/app.js +++ b/web/app.js @@ -29,6 +29,7 @@ import { LlmRequestDetail } from './components/llm-request-detail.js'; import { SessionDetailPage } from './components/session-detail.js'; import { SystemAgentsPage } from './components/system-agents.js'; import { ProjectsPage } from './components/projects/index.js'; +import { FilesPage } from './components/files-page.js'; import { FileViewerPage } from './components/file-viewer-page.js'; import { ToolDetailPage } from './components/tool-detail-page.js'; import { SetupPage } from './components/setup-page.js'; @@ -79,6 +80,7 @@ customElements.define('llm-request-detail', LlmRequestDetail); customElements.define('session-detail-page', SessionDetailPage); customElements.define('system-agents-page', SystemAgentsPage); customElements.define('projects-page', ProjectsPage); +customElements.define('files-page', FilesPage); customElements.define('file-viewer-page', FileViewerPage); customElements.define('tool-detail-page', ToolDetailPage); customElements.define('setup-page', SetupPage); diff --git a/web/components/files-page.js b/web/components/files-page.js new file mode 100644 index 0000000..5e687ca --- /dev/null +++ b/web/components/files-page.js @@ -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 +/// ``, 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` + + `; + } + + _renderRootList() { + if (this._roots === null) { + return html`
${t('common.loading')}
`; + } + return html` +
+ ${t('files.note.roots')} +
+
+ ${this._roots.map(r => this._renderRootRow(r))} +
+ `; + } + + /// 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` +
+ + + ${this._labelFor(this._root)} + + ${this._root.can_write ? nothing : html` + ${t('files.badge.readonly')}`} +
+ this._go( + e.detail.rel ? `${e.detail.root}/${e.detail.rel}` : e.detail.root)} + > + `; + } + + render() { + if (!this._open) return nothing; + return html` +
+ + + ${this._error ? html` +
${this._error}
` : nothing} + +
+ ${this._root ? this._renderExplorer() : this._renderRootList()} +
+
+ `; + } +} diff --git a/web/components/projects/project-board.js b/web/components/projects/project-board.js index 4f07514..b3825c2 100644 --- a/web/components/projects/project-board.js +++ b/web/components/projects/project-board.js @@ -1,11 +1,11 @@ import { html, nothing } from 'lit'; import { LightElement } from '../../lib/base.js'; import { t } from '../../lib/i18n.js'; -import { ProjectFilesPanel } from './project-files.js'; +import '../shared/file-explorer.js'; -/// A project's detail page: header + description, then two tabs — **Files** (a -/// live explorer over the project folder, ``) and -/// **Sharing** (member picker with read/write, mirroring the shared-folders UI). +/// A project's detail page: header + description, then two tabs — **Files** (the +/// shared ``, pointed at the project folder) and **Sharing** +/// (member picker with read/write, mirroring the shared-folders UI). export class ProjectBoardSection extends LightElement { static properties = { _project: { state: true }, @@ -276,13 +276,11 @@ export class ProjectBoardSection extends LightElement { ${this._renderTabs()}
- + ${this._tab === 'sharing' ? this._renderSharePanel() : nothing}
`; } } - -customElements.define('project-files-panel', ProjectFilesPanel); diff --git a/web/components/projects/project-files.js b/web/components/shared/file-explorer.js similarity index 69% rename from web/components/projects/project-files.js rename to web/components/shared/file-explorer.js index ebdac16..4be0342 100644 --- a/web/components/projects/project-files.js +++ b/web/components/shared/file-explorer.js @@ -3,33 +3,60 @@ import { LightElement } from '../../lib/base.js'; import { t } from '../../lib/i18n.js'; import { fileWatcher } from '../../lib/file-watcher.js'; -/// The Files tab of a project board: a live explorer over the project folder. +/// A live file explorer over one subtree of the caller's namespace. /// /// One directory at a time (`GET /api/files/dir`); clicking a folder navigates /// into it, clicking a file opens it in the existing viewer (`window.openFile`). -/// The breadcrumb is rooted at the project folder (shown as `/`). The listing +/// The breadcrumb is rooted at [`root`] (an agent path — a project folder, a +/// shared folder, the home, a memory store), shown as `rootLabel`. The listing /// reloads in real time: the shared `/api/file/watch` socket (the `fileWatcher` -/// singleton) pushes a `changed` event for the open directory whenever another -/// member — or the agent, from inside its container — creates/modifies/removes -/// a file in it. Write actions (new folder, upload, rename, delete) are offered -/// only to members with `can_write` and are gated server-side too. -export class ProjectFilesPanel extends LightElement { +/// singleton) pushes a `changed` event for the open directory whenever someone +/// else — or the agent, from inside its container — creates/modifies/removes a +/// file in it. +/// +/// **Writability is read from the listing, never passed in.** It changes per +/// branch (a shared folder without `can_write`, `skills/`, `docs/`, a memory +/// store), and `/api/files/dir` answers it from the same `UserFs::can_write_to` +/// that the server rejects writes with — so the buttons this offers and the +/// writes the server accepts cannot disagree. A caller that thinks it knows +/// better would be the one place they could. +/// +/// **The current folder is readable and settable, without a two-way binding.** +/// A host that puts the path in the URL (`files-page.js`) needs both halves: +/// `rel` steers the explorer when the hash changes (a deep link, the browser's +/// back button), and `explorer-navigate` reports where the user just went. The +/// loop those two would otherwise form is cut by *what the event means*: it +/// fires only for a click, never for a `rel` the host itself set — so echoing +/// the event back as a property is a no-op, and a host that ignores the event +/// entirely (`project-board.js`) still gets a working explorer. +export class FileExplorer extends LightElement { static properties = { - project: { attribute: false }, - _rel: { state: true }, - _entries: { state: true }, - _loading: { state: true }, - _error: { state: true }, - _busy: { state: true }, - _modal: { state: true }, - _drag: { state: true }, + /// Agent path of the subtree to browse (`~`, `shared/x`, `projects/a/b`, + /// `user-memory`…). Changing it navigates back to that root. + root: { type: String }, + /// What the first breadcrumb crumb reads; the full `root` is its tooltip. + rootLabel: { type: String }, + /// Folder to show, relative to `root` (`''` = the root itself). Optional: + /// leave it unset and the explorer simply keeps its own place. + rel: { type: String }, + _rel: { state: true }, + _entries: { state: true }, + _canWrite: { state: true }, + _loading: { state: true }, + _error: { state: true }, + _busy: { state: true }, + _modal: { state: true }, + _drag: { state: true }, }; constructor() { super(); - this.project = null; - this._rel = ''; // path relative to the project root ('' = root) + this.root = ''; + this.rootLabel = '/'; + this.rel = ''; + this._rel = ''; // path relative to `root` ('' = the root itself) this._entries = null; + this._canWrite = false; this._loading = false; this._error = null; this._busy = false; @@ -41,14 +68,13 @@ export class ProjectFilesPanel extends LightElement { } willUpdate(changed) { - // (Re)open the root only when the project itself changes — a refetch of the - // same project (member edits) must not reset the current folder. - if (changed.has('project')) { - const prev = changed.get('project'); - if (this.project?.root_path && this.project.root_path !== prev?.root_path) { - this._open(''); - } - } + if (!this.root) return; + // Re-anchor only on a real move: a re-render with the same root must not + // throw away the folder the user navigated to, and a `rel` echoing back the + // click that produced it is already where it says (see the class comment). + const movedRoot = changed.has('root') && this.root !== changed.get('root'); + const movedRel = changed.has('rel') && this.rel !== this._rel; + if (movedRoot || movedRel) this._open(this.rel ?? ''); } disconnectedCallback() { @@ -58,8 +84,7 @@ export class ProjectFilesPanel extends LightElement { } _dirPath() { - const root = this.project?.root_path ?? ''; - return this._rel ? `${root}/${this._rel}` : root; + return this._rel ? `${this.root}/${this._rel}` : this.root; } async _open(rel) { @@ -81,13 +106,15 @@ export class ProjectFilesPanel extends LightElement { } async _load() { - if (!this.project?.root_path) return; + if (!this.root) return; this._loading = true; try { const res = await fetch(`/api/files/dir?path=${encodeURIComponent(this._dirPath())}`); if (!res.ok) throw new Error(await res.text()); - this._entries = await res.json(); - this._error = null; + const listing = await res.json(); + this._entries = listing.entries; + this._canWrite = !!listing.can_write; + this._error = null; } catch (e) { this._error = e.message; } finally { @@ -97,32 +124,41 @@ export class ProjectFilesPanel extends LightElement { // ── Navigation ──────────────────────────────────────────────────────────── + /// A move the **user** made: go, then say so. Only this path announces — + /// `_open` stays the silent mechanism the property sync uses. + _navigate(rel) { + this._open(rel); + this.dispatchEvent(new CustomEvent('explorer-navigate', { + bubbles: true, composed: true, detail: { root: this.root, rel }, + })); + } + _enter(entry) { if (entry.is_dir) { - this._open(this._rel ? `${this._rel}/${entry.name}` : entry.name); + this._navigate(this._rel ? `${this._rel}/${entry.name}` : entry.name); } else { window.openFile(entry.path); } } _goTo(index) { - // -1 = project root, otherwise the segment index to land on. + // -1 = the root, otherwise the segment index to land on. const segs = this._rel ? this._rel.split('/') : []; - this._open(index < 0 ? '' : segs.slice(0, index + 1).join('/')); + this._navigate(index < 0 ? '' : segs.slice(0, index + 1).join('/')); } // ── Write actions ───────────────────────────────────────────────────────── _openModal(mode, target = null) { this._modal = { mode, name: target?.name ?? '', target }; - this.updateComplete.then(() => this.querySelector('.pf-modal-input')?.focus()); + this.updateComplete.then(() => this.querySelector('.fx-modal-input')?.focus()); } async _submitModal(e) { e.preventDefault(); const name = (this._modal?.name ?? '').trim(); if (!name || name.includes('/') || name.includes('\\')) { - this._error = t('projects.files.error.name'); + this._error = t('files.error.name'); return; } this._busy = true; @@ -152,7 +188,7 @@ export class ProjectFilesPanel extends LightElement { } async _remove(entry) { - const key = entry.is_dir ? 'projects.files.confirm.delete_dir' : 'projects.files.confirm.delete_file'; + const key = entry.is_dir ? 'files.confirm.delete_dir' : 'files.confirm.delete_file'; if (!confirm(t(key, { name: entry.name }))) return; this._busy = true; try { @@ -189,7 +225,7 @@ export class ProjectFilesPanel extends LightElement { } _pickFiles() { - this.querySelector('.pf-file-input')?.click(); + this.querySelector('.fx-file-input')?.click(); } // ── Rendering ───────────────────────────────────────────────────────────── @@ -202,9 +238,9 @@ export class ProjectFilesPanel extends LightElement {