feat(files): a Files section over the caller's whole space
Nightly Build / build (push) Successful in 5m36s
Nightly Build / build (push) Successful in 5m36s
Until now file browsing existed only inside a project, and the two memory
stores were reachable only by the agent's tools. `#files` is the general
surface: home, both memory stores, the shared folders and projects the
caller belongs to, plus the read-only skills and docs trees.
The root is virtual, and that is the design. Anchoring at `~` is wrong:
the explorer reads host-side, while `shared/`, `projects/`, `skills/` and
`docs/` are bind mounts inside the container — a page rooted at the home
would show less than the user has with no way to reach the rest, and on
native Linux would show Docker's empty mountpoint stubs, a door that
appears to work and leads nowhere. So level 0 is a synthetic list from the
new `GET /api/files/roots`, serialized from the caller's `UserFs` plus the
two virtual memory roots. It sends `kind`, never a label: labels are copy
and get translated.
`GET /api/files/dir` now answers `{ path, can_write, entries }`, and a
memory path is classified before `resolve_view_path` (which refuses one)
and listed from `memory_docs`: one level derived from the flat key space
by the pure `memory_docs::immediate_children`, over a single query whose
unslashed prefix also spots an exact note as "not a directory". Memory is
read-only from the page — every writer routes through `resolve_view_path`,
and `shared-memory/*` is `@fs_write require` for the agent, so a button
that walks past that rule is a decision of its own.
The explorer moves out of projects into `shared/file-explorer.js`, taking
`root` + `rootLabel` and reading `can_write` from the listing rather than
from its host: writability changes per branch and comes from the same
`UserFs::can_write_to` the server rejects writes with, so the buttons
offered and the writes accepted cannot disagree. Deep-linking needed it
steerable without a two-way binding, hence `rel` in and
`explorer-navigate` out — the event fires only for a click, never for a
`rel` the host set, so echoing it back is a no-op.
The URL carries the agent path of the open folder in one parameter, the
same vocabulary the assistant uses, so a link is shareable and pasteable
into a conversation; which root it belongs to is derived, not stored.
docs/: a new files.md, plus two pages this made false — shared-folders.md
claimed in three places that a shared folder has no explorer, and
memory.md never said a user can now read their own notes.
This commit is contained in:
+164
-3
@@ -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) ─────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user