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
+166 -1
View File
@@ -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<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";
@@ -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), ''))
+ CASE WHEN substr(content, -1, 1) = char(10) THEN 0 ELSE 1 END
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
WHERE path LIKE ? ESCAPE '\\'
ORDER BY updated_at DESC",
@@ -154,6 +174,78 @@ pub async fn list_with_metadata(pool: &SqlitePool, prefix: &str) -> Result<Vec<M
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
/// 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<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]
async fn list_by_prefix_and_delete_deindexes() {
let (pool, dir) = owner_pool("list").await;