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
+164 -3
View File
@@ -44,6 +44,45 @@ pub struct DirEntry {
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 {
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())
}
/// 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
/// 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<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<FileQuery>,
) -> Result<Json<Vec<DirEntry>>, ApiError> {
) -> Result<Json<DirListing>, 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<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) ─────────────────────────────────────
+1
View File
@@ -233,6 +233,7 @@ pub fn router() -> Router<Arc<Skald>> {
// 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)