ui: touch parity, saves that survive outages, edits that do not collide

PUT /api/nodes/{id} takes base_updated_at and answers 409 when the node
was saved since; the editor sends it, so a save from another tab, the
phone or the SFTP mount is no longer overwritten in silence. :w! and
:wq! now force the write.

An autosave the server could not take is kept and retried with backoff,
and at once on the online event; network failures say "the server
cannot be reached" rather than "Failed to fetch". At boot that is no
longer shown as a login screen. A session that expires mid-edit shows
the login with a note, and logging back in resumes the open draft.

Folder listings and the media list are read page by page, so more than
500 children are no longer cut off. Search results carry the space and
folder they sit in, and the overview's recent list includes files.
Embedding uploads report progress, like uploads into a folder.

On touch devices vim is off by default. Each row has an actions sheet
(rename, move, share, seal, delete), folders start with a .. row, and
the dock gains today's entry, upload, search all spaces, drop for a
marked node, help, and the board's list, reorder, rename and delete.
The agenda gets a done button per card and a button for today's entry.

Sharing a folder or a file is refused in the client. :mv .. in a search
moves up from the match's own folder. Files dropped outside the list or
editor no longer navigate the tab away. The public share view renders
/n/ links as plain text, and tells a network error from a dead link.
This commit is contained in:
nvrl committed 2026-10-20 05:53:34 +02:00
1 parent ee13ae44fa
commit 2733ac1a3a
15 files changed
+472 -74

No files matched your search

+3
View File
@@ -14,6 +14,8 @@ pub enum AppError {
TooManyRequests(i64),
/// The file is sealed and the vault is closed.
Locked,
/// Written over something newer than the writer had seen.
Conflict(String),
Internal(anyhow::Error),
}
@@ -25,6 +27,7 @@ impl IntoResponse for AppError {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
AppError::Conflict(m) => (StatusCode::CONFLICT, m),
AppError::Locked => (
StatusCode::LOCKED,
"the vault is locked — :unlock opens it".to_string(),
+18 -2
View File
@@ -367,15 +367,31 @@ pub(crate) async fn insert_file(
Ok(id)
}
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
#[derive(Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub offset: Option<i64>,
}
/// A page of the media list, newest first; the client asks for pages until
/// one comes back short.
pub async fn list(
_: Session,
State(state): State<AppState>,
Query(page): Query<ListQuery>,
) -> AppResult<Json<Vec<Media>>> {
let rows = sqlx::query(
"SELECT m.id, m.filename, m.mime, m.size, m.created_at, m.encrypted,
(SELECT f.id FROM nodes f WHERE f.media_id = m.id) AS file_node,
(SELECT group_concat(nm.node_id)
FROM node_media nm JOIN nodes d ON d.id = nm.node_id
WHERE nm.media_id = m.id AND d.kind <> 'file') AS node_ids
FROM media m ORDER BY m.created_at DESC LIMIT 500",
FROM media m ORDER BY m.created_at DESC, m.id LIMIT ?1 OFFSET ?2",
)
.bind(page.limit.unwrap_or(500).clamp(1, 2000))
.bind(page.offset.unwrap_or(0).max(0))
.fetch_all(&state.db)
.await?;
Ok(Json(rows.iter().map(row_to_media).collect()))
+35 -10
View File
@@ -63,6 +63,10 @@ pub struct NodeSummary {
pub share_token: Option<String>,
/// Files only.
pub file: Option<FileInfo>,
/// Search results only: where the match sits, `work/notes`, so two
/// matches with the same name can be told apart.
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
}
#[derive(Serialize)]
@@ -140,6 +144,12 @@ pub struct UpdateInput {
/// Spaces only, and only when it is sent at all.
#[serde(default)]
pub has_board: Option<bool>,
/// The `updated_at` the writer last saw. When the node has been saved
/// since — from another tab, another device, the SFTP mount — the write
/// is refused rather than silently undoing that save. Left out, the
/// write goes through whatever happened in between.
#[serde(default)]
pub base_updated_at: Option<i64>,
}
#[derive(Deserialize)]
@@ -315,6 +325,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
shared: share_token.is_some(),
share_token,
file,
location: None,
}
}
@@ -366,7 +377,7 @@ pub async fn recent_documents(db: &SqlitePool, root: i64, limit: i64) -> AppResu
)
SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
FROM nodes n
WHERE n.kind = 'document' AND n.id IN (SELECT id FROM sub)
WHERE n.kind IN ('document', 'file') AND n.id IN (SELECT id FROM sub)
ORDER BY n.updated_at DESC, n.id DESC
LIMIT ?2"
))
@@ -434,6 +445,7 @@ pub async fn list(
let offset = params.offset.unwrap_or(0).max(0);
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let searching = search.and_then(fts_query).is_some();
let rows = match search.and_then(fts_query) {
// A search reaches through the whole tree, optionally confined to one
// space: the recursive term collects that space and everything under it.
@@ -479,14 +491,21 @@ pub async fn list(
}
};
Ok(Json(
rows.iter()
.map(|row| {
let head: String = row.try_get("head").unwrap_or_default();
row_to_summary(row, &head)
})
.collect(),
))
let mut found: Vec<NodeSummary> = rows
.iter()
.map(|row| {
let head: String = row.try_get("head").unwrap_or_default();
row_to_summary(row, &head)
})
.collect();
if searching {
for node in &mut found {
let path = path_of(&state.db, node.id).await?;
let parents = &path[..path.len().saturating_sub(1)];
node.location = Some(parents.iter().map(|c| c.slug.as_str()).collect::<Vec<_>>().join("/"));
}
}
Ok(Json(found))
}
pub async fn get(
@@ -576,12 +595,18 @@ pub async fn update(
// here is still free when it is written, and a file's name and its
// download name never disagree.
let mut tx = crate::db::begin_write(&state.db).await?;
let row = sqlx::query("SELECT parent_id, kind, name, slug FROM nodes WHERE id = ?1")
let row = sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or(AppError::NotFound)?;
if input.base_updated_at.is_some_and(|base| row.get::<i64, _>("updated_at") > base) {
return Err(AppError::Conflict(
"changed somewhere else since it was opened — :e! takes that version, :w! writes over it".into(),
));
}
let parent_id: Option<i64> = row.get("parent_id");
let kind: String = row.get("kind");
let previous_name: String = row.get("name");