mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
revisions: a save keeps what it replaces
Every write of a document's text keeps the text it replaces, in the same transaction. Autosave writes every few seconds, so an ordinary save keeps one at most every ten minutes; `:w!`, a save over the SFTP mount and a restore always keep one, because each may undo something. A rename alone, or a document that was empty, keeps nothing. `gh` or `:history` lists the open document's earlier versions, the newest first, and shows the highlighted one as a line diff against the text as it is now. Enter brings it back, and what it replaces becomes a version of its own, so a restore is undone the same way. The daily sweep forgets versions older than WORKSPACE_REVISION_DAYS (90) but never the newest 20 of a document, and keeps an upload as long as a version still embeds it. Versions stay out of the export, the mount and the readable mirror; a document in the trash shows none.
This commit is contained in:
1 parent
8ffad6350f
commit
fc2be66b0c
17 files changed
+887
-31
No files matched your search
@@ -108,3 +108,7 @@ WORKSPACE_VAULT_IDLE_MIN=15
|
|||||||
# Days a deleted document, folder, file or space waits in the trash, where
|
# Days a deleted document, folder, file or space waits in the trash, where
|
||||||
# :trash can bring it back, before it and its files are deleted for good.
|
# :trash can bring it back, before it and its files are deleted for good.
|
||||||
WORKSPACE_TRASH_DAYS=30
|
WORKSPACE_TRASH_DAYS=30
|
||||||
|
|
||||||
|
# Days a document's earlier versions are kept for :history. The newest 20 of
|
||||||
|
# every document are kept however old they are.
|
||||||
|
WORKSPACE_REVISION_DAYS=90
|
||||||
@@ -85,6 +85,7 @@ it; in the document pane it is that document. In the browse pane:
|
|||||||
| `]]` / `[[` | the next / previous heading of the open document, in the editor too |
|
| `]]` / `[[` | the next / previous heading of the open document, in the editor too |
|
||||||
| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) |
|
| `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) |
|
||||||
| `gx` | open the file under the cursor in a tab of its own |
|
| `gx` | open the file under the cursor in a tab of its own |
|
||||||
|
| `gh` | the open document's earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) |
|
||||||
| `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own |
|
| `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own |
|
||||||
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
|
| `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
|
||||||
| `o` | a new document here, straight into insert mode |
|
| `o` | a new document here, straight into insert mode |
|
||||||
@@ -255,6 +256,21 @@ space and folder each match is in. A document made with `o` and left with neithe
|
|||||||
it is removed again on the way out, so abandoned starts do not pile up. If a tab dies mid-document, opening it again restores the draft and
|
it is removed again on the way out, so abandoned starts do not pile up. If a tab dies mid-document, opening it again restores the draft and
|
||||||
says so — `:e!` throws it away, the way vim handles a swap file.
|
says so — `:e!` throws it away, the way vim handles a swap file.
|
||||||
|
|
||||||
|
A save keeps what it replaces. `gh` or `:history` lists the open document's
|
||||||
|
earlier versions, the newest first, and shows the highlighted one as a diff
|
||||||
|
against the text as it is now: `-` is what bringing it back would take out,
|
||||||
|
`+` what it would put back. `Enter` brings it back, and the text it replaces
|
||||||
|
becomes a version of its own, so a restore is undone the same way. Autosave
|
||||||
|
writes every few seconds, so an ordinary save keeps a version at most every
|
||||||
|
ten minutes; `:w!`, a save over the SFTP mount and a restore always keep one,
|
||||||
|
because each of them may undo something. A rename alone keeps nothing. The
|
||||||
|
daily sweep forgets versions older than `WORKSPACE_REVISION_DAYS` (90 by
|
||||||
|
default), but never the newest 20 of a document, and an upload stays as long
|
||||||
|
as a version still embeds it. A file that has been sealed since is gone from
|
||||||
|
the old version — sealing deletes the plaintext on purpose. Versions are not
|
||||||
|
in the export, the mount or the readable view on Proton Drive; they are in
|
||||||
|
`diary.db`, and so in its mirror.
|
||||||
|
|
||||||
A delete is not the end of anything. `x`, `dd`, `:delete` and `:rmspace`
|
A delete is not the end of anything. `x`, `dd`, `:delete` and `:rmspace`
|
||||||
put what they delete in the trash, with everything inside it, and `u` puts
|
put what they delete in the trash, with everything inside it, and `u` puts
|
||||||
the last of them back where it was — the same folder, the same name, unless
|
the last of them back where it was — the same folder, the same name, unless
|
||||||
@@ -674,6 +690,8 @@ sealed or not.
|
|||||||
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
|
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
|
||||||
| `POST` | `/api/import` | import an export at the root: directories become spaces |
|
| `POST` | `/api/import` | import an export at the root: directories become spaces |
|
||||||
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
|
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
|
||||||
|
| `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first |
|
||||||
|
| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text |
|
||||||
| `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link |
|
| `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link |
|
||||||
| `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip |
|
| `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip |
|
||||||
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- What a document said before it was written over. A row holds the text that
|
||||||
|
-- was replaced, not the text that replaced it: the newest version is the
|
||||||
|
-- document itself. Autosave writes every few seconds, so an ordinary save
|
||||||
|
-- keeps one at most every ten minutes; `:w!`, a save over the mount and a
|
||||||
|
-- restore always keep one, because each of those may undo something.
|
||||||
|
CREATE TABLE revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
saved_at INTEGER NOT NULL,
|
||||||
|
reason TEXT NOT NULL CHECK (reason IN ('edit', 'force', 'sftp', 'restore'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_revisions_node ON revisions (node_id, saved_at DESC);
|
||||||
@@ -24,6 +24,9 @@ pub struct Config {
|
|||||||
pub vault_idle: std::time::Duration,
|
pub vault_idle: std::time::Duration,
|
||||||
/// How many days a deleted node waits in the trash before it is purged.
|
/// How many days a deleted node waits in the trash before it is purged.
|
||||||
pub trash_days: i64,
|
pub trash_days: i64,
|
||||||
|
/// How many days a document's earlier versions are kept, past the newest
|
||||||
|
/// few that always are.
|
||||||
|
pub revision_days: i64,
|
||||||
pub backup: BackupConfig,
|
pub backup: BackupConfig,
|
||||||
pub mail: MailConfig,
|
pub mail: MailConfig,
|
||||||
pub sftp: SftpConfig,
|
pub sftp: SftpConfig,
|
||||||
@@ -44,6 +47,7 @@ impl std::fmt::Debug for Config {
|
|||||||
.field("secure_cookie", &self.secure_cookie)
|
.field("secure_cookie", &self.secure_cookie)
|
||||||
.field("vault_idle", &self.vault_idle)
|
.field("vault_idle", &self.vault_idle)
|
||||||
.field("trash_days", &self.trash_days)
|
.field("trash_days", &self.trash_days)
|
||||||
|
.field("revision_days", &self.revision_days)
|
||||||
.field("backup", &self.backup)
|
.field("backup", &self.backup)
|
||||||
.field("mail", &self.mail)
|
.field("mail", &self.mail)
|
||||||
.field("sftp", &self.sftp)
|
.field("sftp", &self.sftp)
|
||||||
@@ -293,6 +297,7 @@ impl Config {
|
|||||||
// A day at least, so an undo is never raced by the sweep; ten years
|
// A day at least, so an undo is never raced by the sweep; ten years
|
||||||
// at most, as for the sessions.
|
// at most, as for the sessions.
|
||||||
let trash_days = number("TRASH_DAYS", 30, 1..=3650)? as i64;
|
let trash_days = number("TRASH_DAYS", 30, 1..=3650)? as i64;
|
||||||
|
let revision_days = number("REVISION_DAYS", 90, 1..=3650)? as i64;
|
||||||
let secure_cookie = flag("SECURE_COOKIE")?;
|
let secure_cookie = flag("SECURE_COOKIE")?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -306,6 +311,7 @@ impl Config {
|
|||||||
secure_cookie,
|
secure_cookie,
|
||||||
vault_idle,
|
vault_idle,
|
||||||
trash_days,
|
trash_days,
|
||||||
|
revision_days,
|
||||||
backup: BackupConfig::from_env()?,
|
backup: BackupConfig::from_env()?,
|
||||||
mail: MailConfig::from_env()?,
|
mail: MailConfig::from_env()?,
|
||||||
sftp: SftpConfig::from_env()?,
|
sftp: SftpConfig::from_env()?,
|
||||||
@@ -344,6 +350,7 @@ impl Config {
|
|||||||
secure_cookie: false,
|
secure_cookie: false,
|
||||||
vault_idle: std::time::Duration::from_secs(60),
|
vault_idle: std::time::Duration::from_secs(60),
|
||||||
trash_days: 30,
|
trash_days: 30,
|
||||||
|
revision_days: 90,
|
||||||
backup: BackupConfig {
|
backup: BackupConfig {
|
||||||
device_name: "narl-diary".into(),
|
device_name: "narl-diary".into(),
|
||||||
interval: None,
|
interval: None,
|
||||||
|
|||||||
+5
-1
@@ -120,7 +120,11 @@ async fn main() -> Result<()> {
|
|||||||
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
|
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
|
||||||
notify::spawn(Arc::clone(¬ify));
|
notify::spawn(Arc::clone(¬ify));
|
||||||
|
|
||||||
sweep::spawn(db.clone(), config.uploads_dir(), config.trash_days);
|
sweep::spawn(
|
||||||
|
db.clone(),
|
||||||
|
config.uploads_dir(),
|
||||||
|
sweep::Keep::from(&*config),
|
||||||
|
);
|
||||||
|
|
||||||
let epoch = auth::Epoch::load(&db, &config).await?;
|
let epoch = auth::Epoch::load(&db, &config).await?;
|
||||||
let sessions = Arc::new(auth::Sessions::load(&db).await?);
|
let sessions = Arc::new(auth::Sessions::load(&db).await?);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod inspect;
|
|||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
|
pub mod revisions;
|
||||||
pub mod seal;
|
pub mod seal;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
@@ -69,6 +70,8 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
|||||||
.route("/nodes/{id}", get(nodes::get))
|
.route("/nodes/{id}", get(nodes::get))
|
||||||
.route("/nodes/{id}", put(nodes::update))
|
.route("/nodes/{id}", put(nodes::update))
|
||||||
.route("/nodes/{id}", delete(nodes::remove))
|
.route("/nodes/{id}", delete(nodes::remove))
|
||||||
|
.route("/nodes/{id}/revisions", get(revisions::list))
|
||||||
|
.route("/nodes/{id}/revisions/{rev}", get(revisions::get))
|
||||||
.route("/nodes/move", post(nodes::move_many))
|
.route("/nodes/move", post(nodes::move_many))
|
||||||
.route("/nodes/{id}/move", post(nodes::move_node))
|
.route("/nodes/{id}/move", post(nodes::move_node))
|
||||||
.route("/import", post(import::import_root).layer(uploads))
|
.route("/import", post(import::import_root).layer(uploads))
|
||||||
|
|||||||
+13
-1
@@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool};
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::Session,
|
auth::Session,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{media, trash},
|
routes::{media, revisions, trash},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,6 +162,10 @@ pub struct UpdateInput {
|
|||||||
/// write goes through whatever happened in between.
|
/// write goes through whatever happened in between.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub base_updated_at: Option<i64>,
|
pub base_updated_at: Option<i64>,
|
||||||
|
/// The text sent is a revision brought back: what it replaces is kept
|
||||||
|
/// as a revision of its own, however recently the last one was.
|
||||||
|
#[serde(default)]
|
||||||
|
pub restore: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -702,6 +706,14 @@ pub async fn update(
|
|||||||
} else {
|
} else {
|
||||||
input.body.as_str()
|
input.body.as_str()
|
||||||
};
|
};
|
||||||
|
let reason = if input.restore {
|
||||||
|
revisions::Reason::Restore
|
||||||
|
} else if input.base_updated_at.is_none() {
|
||||||
|
revisions::Reason::Force
|
||||||
|
} else {
|
||||||
|
revisions::Reason::Edit
|
||||||
|
};
|
||||||
|
revisions::keep(&mut tx, id, body, reason).await?;
|
||||||
if kind == FILE {
|
if kind == FILE {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)",
|
"UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)",
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
//! Revisions: what a document said before it was written over, so a `:w!`
|
||||||
|
//! that should not have been, a save over the mount, or a paragraph deleted
|
||||||
|
//! last week can be taken back.
|
||||||
|
//!
|
||||||
|
//! A revision is kept by the write that replaces the text, in the same
|
||||||
|
//! transaction, so there is never a save without the version it replaced.
|
||||||
|
//! The sweep forgets them after `WORKSPACE_REVISION_DAYS`, but always keeps
|
||||||
|
//! the newest few of every document.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::{Row, SqliteConnection, SqlitePool};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::Session,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
routes::trash,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Why a revision was kept.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Reason {
|
||||||
|
/// An ordinary save, autosave included.
|
||||||
|
Edit,
|
||||||
|
/// `:w!`: a write that did not check what it wrote over.
|
||||||
|
Force,
|
||||||
|
/// A save over the SFTP mount.
|
||||||
|
Sftp,
|
||||||
|
/// A revision brought back, replacing what was there.
|
||||||
|
Restore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reason {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Reason::Edit => "edit",
|
||||||
|
Reason::Force => "force",
|
||||||
|
Reason::Sftp => "sftp",
|
||||||
|
Reason::Restore => "restore",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Autosave writes every few seconds; an ordinary save keeps a revision only
|
||||||
|
/// when the newest one is at least this old.
|
||||||
|
const EDIT_GAP: i64 = 10 * 60;
|
||||||
|
|
||||||
|
/// How many of a document's revisions the sweep never forgets, however old.
|
||||||
|
pub const ALWAYS_KEPT: i64 = 20;
|
||||||
|
|
||||||
|
/// Keep what a document says now, before `new_body` replaces it. Called by
|
||||||
|
/// every write of a document's text, in its transaction. Returns whether a
|
||||||
|
/// revision was kept.
|
||||||
|
pub async fn keep(
|
||||||
|
db: &mut SqliteConnection,
|
||||||
|
id: i64,
|
||||||
|
new_body: &str,
|
||||||
|
reason: Reason,
|
||||||
|
) -> AppResult<bool> {
|
||||||
|
let Some(row) = sqlx::query("SELECT kind, name, body FROM nodes WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&mut *db)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let body: String = row.get("body");
|
||||||
|
// Only text is worth keeping: a rename is not a loss, and an empty
|
||||||
|
// document has nothing to lose.
|
||||||
|
if row.get::<String, _>("kind") != crate::routes::nodes::DOCUMENT
|
||||||
|
|| body == new_body
|
||||||
|
|| body.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = crate::now();
|
||||||
|
if reason == Reason::Edit {
|
||||||
|
let newest: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT MAX(saved_at) FROM revisions WHERE node_id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&mut *db)
|
||||||
|
.await?;
|
||||||
|
if newest.is_some_and(|at| now - at < EDIT_GAP) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO revisions (node_id, name, body, saved_at, reason)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(row.get::<String, _>("name"))
|
||||||
|
.bind(&body)
|
||||||
|
.bind(now)
|
||||||
|
.bind(reason.as_str())
|
||||||
|
.execute(&mut *db)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the revisions saved before `cutoff`, except the newest
|
||||||
|
/// `ALWAYS_KEPT` of each document. Returns how many went.
|
||||||
|
pub async fn forget_older(db: &SqlitePool, cutoff: i64) -> sqlx::Result<u64> {
|
||||||
|
let done = sqlx::query(
|
||||||
|
"DELETE FROM revisions
|
||||||
|
WHERE saved_at < ?1
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT r.id FROM revisions r
|
||||||
|
WHERE r.node_id = revisions.node_id
|
||||||
|
ORDER BY r.saved_at DESC, r.id DESC
|
||||||
|
LIMIT ?2
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(cutoff)
|
||||||
|
.bind(ALWAYS_KEPT)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(done.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RevisionSummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub saved_at: i64,
|
||||||
|
pub reason: String,
|
||||||
|
/// Characters of text.
|
||||||
|
pub size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Revision {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub saved_at: i64,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document in the trash has no history to read, as it has no text: both
|
||||||
|
/// come back with it.
|
||||||
|
async fn readable(state: &AppState, id: i64) -> AppResult<()> {
|
||||||
|
if trash::in_trash(&mut *state.db.acquire().await?, id).await? {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/nodes/{id}/revisions` — the newest first.
|
||||||
|
pub async fn list(
|
||||||
|
_: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Vec<RevisionSummary>>> {
|
||||||
|
readable(&state, id).await?;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, name, saved_at, reason, length(body) AS size FROM revisions
|
||||||
|
WHERE node_id = ?1 ORDER BY saved_at DESC, id DESC",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| RevisionSummary {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
saved_at: row.get("saved_at"),
|
||||||
|
reason: row.get("reason"),
|
||||||
|
size: row.get("size"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/nodes/{id}/revisions/{rev}` — one, with its text.
|
||||||
|
pub async fn get(
|
||||||
|
_: Session,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((id, rev)): Path<(i64, i64)>,
|
||||||
|
) -> AppResult<Json<Revision>> {
|
||||||
|
readable(&state, id).await?;
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, name, body, saved_at, reason FROM revisions WHERE id = ?1 AND node_id = ?2",
|
||||||
|
)
|
||||||
|
.bind(rev)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
Ok(Json(Revision {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
body: row.get("body"),
|
||||||
|
saved_at: row.get("saved_at"),
|
||||||
|
reason: row.get("reason"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{forget_older, keep, Reason, ALWAYS_KEPT};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
async fn db() -> SqlitePool {
|
||||||
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::migrate!("./migrations").run(&db).await.unwrap();
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn document(db: &SqlitePool, slug: &str, body: &str) -> i64 {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
|
||||||
|
VALUES ((SELECT id FROM nodes WHERE slug = 'work'), 'document', ?1, ?1, ?2, 0, 0)
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(body)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(db: &SqlitePool, id: i64, body: &str, reason: Reason) -> bool {
|
||||||
|
let mut conn = db.acquire().await.unwrap();
|
||||||
|
let kept = keep(&mut conn, id, body, reason).await.unwrap();
|
||||||
|
sqlx::query("UPDATE nodes SET body = ?1 WHERE id = ?2")
|
||||||
|
.bind(body)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
kept
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bodies(db: &SqlitePool, id: i64) -> Vec<String> {
|
||||||
|
sqlx::query_scalar("SELECT body FROM revisions WHERE node_id = ?1 ORDER BY id")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Autosave keeps one revision per ten minutes; `:w!` and a save over
|
||||||
|
/// the mount always keep one; nothing is kept of an unchanged text or
|
||||||
|
/// of an empty one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn what_a_write_keeps() {
|
||||||
|
let db = db().await;
|
||||||
|
let id = document(&db, "plan", "").await;
|
||||||
|
assert!(!write(&db, id, "one", Reason::Edit).await);
|
||||||
|
assert!(write(&db, id, "two", Reason::Edit).await);
|
||||||
|
assert!(!write(&db, id, "three", Reason::Edit).await);
|
||||||
|
assert!(!write(&db, id, "three", Reason::Force).await);
|
||||||
|
assert!(write(&db, id, "four", Reason::Force).await);
|
||||||
|
assert!(write(&db, id, "five", Reason::Sftp).await);
|
||||||
|
assert_eq!(bodies(&db, id).await, ["one", "three", "four"]);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE revisions SET saved_at = saved_at - 3600")
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(write(&db, id, "six", Reason::Edit).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Through the API: a save that checked what it wrote over keeps one
|
||||||
|
/// revision per ten minutes, `:w!` and a restore always keep one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn saves_keep_what_they_replace() {
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
|
||||||
|
let state = crate::routes::tests::state_in(".".into()).await;
|
||||||
|
let id = document(&state.db, "plan", "first").await;
|
||||||
|
let save = |body: &'static str, base: bool, restore: bool| {
|
||||||
|
let state = state.clone();
|
||||||
|
async move {
|
||||||
|
let input = serde_json::from_value(serde_json::json!({
|
||||||
|
"name": "plan",
|
||||||
|
"body": body,
|
||||||
|
"base_updated_at": base.then_some(i64::MAX),
|
||||||
|
"restore": restore,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let _saved = crate::routes::nodes::update(
|
||||||
|
crate::auth::Session { id: "test".into() },
|
||||||
|
State(state),
|
||||||
|
Path(id),
|
||||||
|
axum::Json(input),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
save("second", true, false).await;
|
||||||
|
save("third", true, false).await;
|
||||||
|
save("fourth", false, false).await;
|
||||||
|
save("first", true, true).await;
|
||||||
|
|
||||||
|
let kept: Vec<(String, String)> =
|
||||||
|
sqlx::query_as("SELECT body, reason FROM revisions WHERE node_id = ?1 ORDER BY id")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let kept: Vec<(&str, &str)> = kept.iter().map(|(b, r)| (b.as_str(), r.as_str())).collect();
|
||||||
|
assert_eq!(
|
||||||
|
kept,
|
||||||
|
[("first", "edit"), ("third", "force"), ("fourth", "restore")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Old revisions go, except the newest few of every document.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_newest_stay_however_old() {
|
||||||
|
let db = db().await;
|
||||||
|
let busy = document(&db, "busy", "start").await;
|
||||||
|
let quiet = document(&db, "quiet", "start").await;
|
||||||
|
for n in 0..ALWAYS_KEPT + 5 {
|
||||||
|
write(&db, busy, &format!("busy {n}"), Reason::Force).await;
|
||||||
|
}
|
||||||
|
write(&db, quiet, "only", Reason::Force).await;
|
||||||
|
sqlx::query("UPDATE revisions SET saved_at = id")
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(forget_older(&db, i64::MAX).await.unwrap(), 5);
|
||||||
|
assert_eq!(bodies(&db, busy).await.len() as i64, ALWAYS_KEPT);
|
||||||
|
assert_eq!(bodies(&db, busy).await[0], "busy 4");
|
||||||
|
assert_eq!(bodies(&db, quiet).await, ["start"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -35,7 +35,7 @@ use crate::{
|
|||||||
Backup,
|
Backup,
|
||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
routes::{media, nodes, seal, trash},
|
routes::{media, nodes, revisions, seal, trash},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
vault,
|
vault,
|
||||||
};
|
};
|
||||||
@@ -757,6 +757,14 @@ impl SpaceFs {
|
|||||||
let (title, body) = split_heading(&text);
|
let (title, body) = split_heading(&text);
|
||||||
let now = crate::now();
|
let now = crate::now();
|
||||||
self.own_writes.insert(id, now);
|
self.own_writes.insert(id, now);
|
||||||
|
// One transaction, so the text this replaces is kept as a revision
|
||||||
|
// exactly when it is replaced.
|
||||||
|
let mut tx = crate::db::begin_write(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
revisions::keep(&mut tx, id, &body, revisions::Reason::Sftp)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE nodes SET name = COALESCE(?1, name), body = ?2, updated_at = ?3 WHERE id = ?4",
|
"UPDATE nodes SET name = COALESCE(?1, name), body = ?2, updated_at = ?3 WHERE id = ?4",
|
||||||
)
|
)
|
||||||
@@ -764,12 +772,13 @@ impl SpaceFs {
|
|||||||
.bind(&body)
|
.bind(&body)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.db)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
media::link_to_node(&mut *self.conn().await?, id, &body)
|
media::link_to_node(&mut tx, id, &body)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
.map_err(|_| StatusCode::Failure)?;
|
||||||
|
tx.commit().await.map_err(|_| StatusCode::Failure)?;
|
||||||
self.changed();
|
self.changed();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2483,6 +2492,15 @@ mod tests {
|
|||||||
(id, "Renamed", "v2\n")
|
(id, "Renamed", "v2\n")
|
||||||
);
|
);
|
||||||
assert!(fs.scratch.is_empty());
|
assert!(fs.scratch.is_empty());
|
||||||
|
|
||||||
|
// The save over the mount kept what it replaced.
|
||||||
|
let kept: Vec<(String, String)> =
|
||||||
|
sqlx::query_as("SELECT body, reason FROM revisions WHERE node_id = ?1 ORDER BY id")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&fs.db)
|
||||||
|
.await
|
||||||
|
.expect("revisions");
|
||||||
|
assert_eq!(kept, [("v1\n".to_string(), "sftp".to_string())]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+75
-20
@@ -1,7 +1,8 @@
|
|||||||
//! Housekeeping for `uploads/`: what a crash, a killed upload or a forgotten
|
//! Housekeeping for `uploads/`: what a crash, a killed upload or a forgotten
|
||||||
//! embed leaves behind. Nothing else ever deletes these, so without this they
|
//! embed leaves behind. Nothing else ever deletes these, so without this they
|
||||||
//! would only grow — on the disk and in every backup of it. The trash is
|
//! would only grow — on the disk and in every backup of it. The trash is
|
||||||
//! emptied here too, of what has waited in it long enough.
|
//! emptied here too, of what has waited in it long enough, and the revisions
|
||||||
|
//! of documents are thinned out.
|
||||||
|
|
||||||
use std::{collections::HashSet, path::Path, time::Duration};
|
use std::{collections::HashSet, path::Path, time::Duration};
|
||||||
|
|
||||||
@@ -19,22 +20,39 @@ const UNREFERENCED_DAYS: i64 = 7;
|
|||||||
|
|
||||||
const EVERY: Duration = Duration::from_secs(24 * 60 * 60);
|
const EVERY: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) {
|
/// How long what the sweep thins out is kept, in days.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct Keep {
|
||||||
|
pub trash_days: i64,
|
||||||
|
pub revision_days: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&crate::config::Config> for Keep {
|
||||||
|
fn from(config: &crate::config::Config) -> Self {
|
||||||
|
Self {
|
||||||
|
trash_days: config.trash_days,
|
||||||
|
revision_days: config.revision_days,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf, keep: Keep) {
|
||||||
crate::tasks::supervise("the uploads sweep", move || {
|
crate::tasks::supervise("the uploads sweep", move || {
|
||||||
let (db, uploads) = (db.clone(), uploads.clone());
|
let (db, uploads) = (db.clone(), uploads.clone());
|
||||||
sweep_forever(db, uploads, trash_days)
|
sweep_forever(db, uploads, keep)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) {
|
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, keep: Keep) {
|
||||||
{
|
{
|
||||||
let mut ticker = tokio::time::interval(EVERY);
|
let mut ticker = tokio::time::interval(EVERY);
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
match run(&db, &uploads, trash_days).await {
|
match run(&db, &uploads, keep).await {
|
||||||
Ok(swept) if swept.any() => tracing::info!(
|
Ok(swept) if swept.any() => tracing::info!(
|
||||||
"swept: {} out of the trash; uploads: {} partial, {} without a row, {} that nothing embeds",
|
"swept: {} out of the trash, {} old revisions; uploads: {} partial, {} without a row, {} that nothing embeds",
|
||||||
swept.trashed,
|
swept.trashed,
|
||||||
|
swept.revisions,
|
||||||
swept.partial,
|
swept.partial,
|
||||||
swept.stray,
|
swept.stray,
|
||||||
swept.unreferenced
|
swept.unreferenced
|
||||||
@@ -50,6 +68,8 @@ async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, trash_days:
|
|||||||
pub struct Swept {
|
pub struct Swept {
|
||||||
/// Things deleted long enough ago to be purged from the trash.
|
/// Things deleted long enough ago to be purged from the trash.
|
||||||
pub trashed: usize,
|
pub trashed: usize,
|
||||||
|
/// Revisions older than they are kept for.
|
||||||
|
pub revisions: u64,
|
||||||
pub partial: usize,
|
pub partial: usize,
|
||||||
pub stray: usize,
|
pub stray: usize,
|
||||||
pub unreferenced: usize,
|
pub unreferenced: usize,
|
||||||
@@ -57,19 +77,23 @@ pub struct Swept {
|
|||||||
|
|
||||||
impl Swept {
|
impl Swept {
|
||||||
fn any(&self) -> bool {
|
fn any(&self) -> bool {
|
||||||
self.trashed + self.partial + self.stray + self.unreferenced > 0
|
self.trashed + self.revisions as usize + self.partial + self.stray + self.unreferenced > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Result<Swept> {
|
pub async fn run(db: &SqlitePool, uploads: &Path, keep: Keep) -> anyhow::Result<Swept> {
|
||||||
// First, so the files only the purged nodes held are released and go in
|
// First, so the files only the purged nodes and the forgotten revisions
|
||||||
// this same pass.
|
// held are released and go in this same pass.
|
||||||
let trashed =
|
let trashed =
|
||||||
crate::routes::trash::purge_older(db, uploads, crate::now() - trash_days * 86_400)
|
crate::routes::trash::purge_older(db, uploads, crate::now() - keep.trash_days * 86_400)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("emptying the trash: {e:?}"))?;
|
.map_err(|e| anyhow::anyhow!("emptying the trash: {e:?}"))?;
|
||||||
|
let revisions =
|
||||||
|
crate::routes::revisions::forget_older(db, crate::now() - keep.revision_days * 86_400)
|
||||||
|
.await?;
|
||||||
let mut swept = Swept {
|
let mut swept = Swept {
|
||||||
trashed,
|
trashed,
|
||||||
|
revisions,
|
||||||
..Swept::default()
|
..Swept::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +113,9 @@ pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Re
|
|||||||
-- Belt and braces: a body or a card that names the file keeps it,
|
-- Belt and braces: a body or a card that names the file keeps it,
|
||||||
-- whatever the link table says.
|
-- whatever the link table says.
|
||||||
AND NOT EXISTS (SELECT 1 FROM nodes n WHERE instr(n.body, m.id) > 0)
|
AND NOT EXISTS (SELECT 1 FROM nodes n WHERE instr(n.body, m.id) > 0)
|
||||||
AND NOT EXISTS (SELECT 1 FROM cards c WHERE instr(c.body, m.id) > 0)",
|
AND NOT EXISTS (SELECT 1 FROM cards c WHERE instr(c.body, m.id) > 0)
|
||||||
|
-- An earlier version of a document brings its pictures back with it.
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM revisions r WHERE instr(r.body, m.id) > 0)",
|
||||||
)
|
)
|
||||||
.bind(cutoff)
|
.bind(cutoff)
|
||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
@@ -141,7 +167,7 @@ pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Re
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{run, Swept};
|
use super::{run, Keep, Swept};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn only_what_nothing_knows_about_goes() {
|
async fn only_what_nothing_knows_about_goes() {
|
||||||
@@ -158,7 +184,8 @@ mod tests {
|
|||||||
let embedded = "22222222-2222-4222-8222-222222222222";
|
let embedded = "22222222-2222-4222-8222-222222222222";
|
||||||
let forgotten = "33333333-3333-4333-8333-333333333333";
|
let forgotten = "33333333-3333-4333-8333-333333333333";
|
||||||
let stray = "44444444-4444-4444-8444-444444444444";
|
let stray = "44444444-4444-4444-8444-444444444444";
|
||||||
for id in [kept, embedded, forgotten] {
|
let remembered = "66666666-6666-4666-8666-666666666666";
|
||||||
|
for id in [kept, embedded, forgotten, remembered] {
|
||||||
sqlx::query("INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, 'x', 'image/png', 1, ?2)")
|
sqlx::query("INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, 'x', 'image/png', 1, ?2)")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(old)
|
.bind(old)
|
||||||
@@ -187,9 +214,26 @@ mod tests {
|
|||||||
.execute(&db)
|
.execute(&db)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// `remembered` only by an earlier version of that document.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO revisions (node_id, name, body, saved_at, reason)
|
||||||
|
SELECT id, 'a', ?1, 0, 'edit' FROM nodes WHERE slug = 'a'",
|
||||||
|
)
|
||||||
|
.bind(format!(""))
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let hour_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
|
let hour_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
|
||||||
for name in [kept, embedded, forgotten, stray, "x.part", "notes.txt"] {
|
for name in [
|
||||||
|
kept,
|
||||||
|
embedded,
|
||||||
|
remembered,
|
||||||
|
forgotten,
|
||||||
|
stray,
|
||||||
|
"x.part",
|
||||||
|
"notes.txt",
|
||||||
|
] {
|
||||||
let path = dir.path().join(name);
|
let path = dir.path().join(name);
|
||||||
std::fs::write(&path, b"x").unwrap();
|
std::fs::write(&path, b"x").unwrap();
|
||||||
std::fs::File::options()
|
std::fs::File::options()
|
||||||
@@ -202,11 +246,21 @@ mod tests {
|
|||||||
let fresh = "55555555-5555-4555-8555-555555555555.part";
|
let fresh = "55555555-5555-4555-8555-555555555555.part";
|
||||||
std::fs::write(dir.path().join(fresh), b"x").unwrap();
|
std::fs::write(dir.path().join(fresh), b"x").unwrap();
|
||||||
|
|
||||||
let swept = run(&db, dir.path(), 30).await.unwrap();
|
let swept = run(
|
||||||
|
&db,
|
||||||
|
dir.path(),
|
||||||
|
Keep {
|
||||||
|
trash_days: 30,
|
||||||
|
revision_days: 90,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
swept,
|
swept,
|
||||||
Swept {
|
Swept {
|
||||||
trashed: 0,
|
trashed: 0,
|
||||||
|
revisions: 0,
|
||||||
partial: 1,
|
partial: 1,
|
||||||
stray: 2,
|
stray: 2,
|
||||||
unreferenced: 1
|
unreferenced: 1
|
||||||
@@ -217,10 +271,11 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||||
.collect();
|
.collect();
|
||||||
let expected: std::collections::BTreeSet<String> = [kept, embedded, "notes.txt", fresh]
|
let expected: std::collections::BTreeSet<String> =
|
||||||
.iter()
|
[kept, embedded, remembered, "notes.txt", fresh]
|
||||||
.map(|s| s.to_string())
|
.iter()
|
||||||
.collect();
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
assert_eq!(left, expected);
|
assert_eq!(left, expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+35
-1
@@ -15,6 +15,7 @@
|
|||||||
import Overview from './lib/Overview.svelte'
|
import Overview from './lib/Overview.svelte'
|
||||||
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
|
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
|
||||||
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
|
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
|
||||||
|
import History, { moveHistory, restoreShown } from './lib/History.svelte'
|
||||||
import Actions from './lib/Actions.svelte'
|
import Actions from './lib/Actions.svelte'
|
||||||
import Prompt from './lib/Prompt.svelte'
|
import Prompt from './lib/Prompt.svelte'
|
||||||
import SecretPrompt from './lib/SecretPrompt.svelte'
|
import SecretPrompt from './lib/SecretPrompt.svelte'
|
||||||
@@ -421,6 +422,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The history's keys: j/k through the versions, Enter brings the one shown back. */
|
||||||
|
function historyKey(event: KeyboardEvent): boolean {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'j':
|
||||||
|
case 'ArrowDown':
|
||||||
|
moveHistory(1)
|
||||||
|
return true
|
||||||
|
case 'k':
|
||||||
|
case 'ArrowUp':
|
||||||
|
moveHistory(-1)
|
||||||
|
return true
|
||||||
|
case 'G':
|
||||||
|
moveHistory(workspace.history.length)
|
||||||
|
return true
|
||||||
|
case 'Enter':
|
||||||
|
void restoreShown()
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* `F`: the hints are pinned where the targets are, so a pane still sliding
|
* `F`: the hints are pinned where the targets are, so a pane still sliding
|
||||||
* in — a space switch, the board just opened — is let settle first.
|
* in — a space switch, the board just opened — is let settle first.
|
||||||
@@ -608,6 +631,12 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (workspace.overlay === 'history' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
|
if (historyKey(event)) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
if (trashKey(event)) {
|
if (trashKey(event)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -759,6 +788,11 @@
|
|||||||
void runCommand('outline')
|
void runCommand('outline')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (previous === 'g' && key === 'h') {
|
||||||
|
event.preventDefault()
|
||||||
|
void runCommand('history')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (previous === 'g' && key === 'x') {
|
if (previous === 'g' && key === 'x') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openInTab()
|
openInTab()
|
||||||
@@ -1041,7 +1075,7 @@
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
||||||
>
|
>
|
||||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else}<MediaBrowser />{/if}
|
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else}<MediaBrowser />{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
['y', 'mint a new share link and copy it — asks, the old one stops working'],
|
['y', 'mint a new share link and copy it — asks, the old one stops working'],
|
||||||
['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'],
|
['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'],
|
||||||
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
|
['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'],
|
||||||
|
['gh', "the open document's earlier versions, as diffs — Enter brings one back"],
|
||||||
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
|
[':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'],
|
||||||
['Ctrl-S', 'save, from anywhere'],
|
['Ctrl-S', 'save, from anywhere'],
|
||||||
['?', 'this screen — in the editor too'],
|
['?', 'this screen — in the editor too'],
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import type { RevisionSummary } from './api'
|
||||||
|
import { workspace } from './store.svelte'
|
||||||
|
|
||||||
|
/** What Enter and the button do: the shown revision becomes the document again. */
|
||||||
|
export async function restoreShown() {
|
||||||
|
const revision = workspace.revision
|
||||||
|
if (!revision) return
|
||||||
|
if (!(await workspace.ask(`bring back the version from ${formatStamp(revision.saved_at)}? the text now is kept in :history`))) return
|
||||||
|
await workspace.guard(() => workspace.restoreRevision(revision))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveHistory(by: number) {
|
||||||
|
const last = Math.max(workspace.history.length - 1, 0)
|
||||||
|
const index = Math.min(Math.max(workspace.historyCursor + by, 0), last)
|
||||||
|
if (index !== workspace.historyCursor) void workspace.guard(() => workspace.showRevision(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASONS: Record<string, string> = {
|
||||||
|
edit: 'saved over',
|
||||||
|
force: 'written over by :w!',
|
||||||
|
sftp: 'saved over the mount',
|
||||||
|
restore: 'replaced by a restore',
|
||||||
|
}
|
||||||
|
export const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { diffLines, folded } from './diff'
|
||||||
|
import { formatStamp, relative } from './util'
|
||||||
|
|
||||||
|
let list = $state<HTMLUListElement>()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const index = workspace.historyCursor
|
||||||
|
list?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Against the text as it is now: what restoring would take out and put back.
|
||||||
|
const rows = $derived(workspace.revision ? folded(diffLines(workspace.draft.body, workspace.revision.body)) : [])
|
||||||
|
const changed = $derived(rows.some((row) => row.kind === 'gone' || row.kind === 'added'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sheet">
|
||||||
|
<header>
|
||||||
|
<span class="label rule">
|
||||||
|
<span class="marker"></span><span class="title">history</span>
|
||||||
|
{workspace.open?.name.trim() || workspace.open?.slug} · {workspace.history.length} earlier
|
||||||
|
</span>
|
||||||
|
{#if workspace.revision}
|
||||||
|
<button onclick={restoreShown}>restore</button>
|
||||||
|
{/if}
|
||||||
|
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<ul bind:this={list}>
|
||||||
|
{#each workspace.history as summary, index (summary.id)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||||
|
<li class:at={index === workspace.historyCursor} data-index={index} onclick={() => workspace.guard(() => workspace.showRevision(index))}>
|
||||||
|
<span class="when">{formatStamp(summary.saved_at)}</span>
|
||||||
|
<span class="muted">{relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars</span>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="muted">no earlier versions yet — one is kept when a save replaces the text.</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="diff">
|
||||||
|
{#if workspace.revision}
|
||||||
|
{#if workspace.revision.name !== workspace.draft.name}
|
||||||
|
<div class="row gone">- # {workspace.draft.name}</div>
|
||||||
|
<div class="row added">+ # {workspace.revision.name}</div>
|
||||||
|
{/if}
|
||||||
|
{#each rows as row, index (index)}
|
||||||
|
{#if row.kind === 'fold'}
|
||||||
|
<div class="row fold">··· {row.count} unchanged</div>
|
||||||
|
{:else}
|
||||||
|
<div class="row {row.kind}">{row.kind === 'gone' ? '-' : row.kind === 'added' ? '+' : ' '} {row.text}</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if !changed}
|
||||||
|
<div class="row fold">the text is the same as now</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="label">
|
||||||
|
<span class="kbd">j</span><span class="kbd">k</span> earlier / later
|
||||||
|
<span class="gap"></span>
|
||||||
|
<span class="kbd">enter</span> restore
|
||||||
|
<span class="gap"></span>
|
||||||
|
<span class="muted">- goes, + comes back</span>
|
||||||
|
<span class="gap"></span>
|
||||||
|
<span class="kbd">q</span><span class="kbd">esc</span> close
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
height: 100%;
|
||||||
|
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||||
|
border: 1px solid var(--surface-alt);
|
||||||
|
border-radius: var(--frame-radius);
|
||||||
|
padding: 20px 24px;
|
||||||
|
animation: resolve var(--t-slow) var(--ease-snap) both;
|
||||||
|
width: min(100%, 140ch);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
header { display: flex; align-items: center; gap: 12px; }
|
||||||
|
header .label { flex: 1; }
|
||||||
|
header .marker { background: var(--light); }
|
||||||
|
header .title { color: var(--light); }
|
||||||
|
|
||||||
|
.split { display: flex; gap: 18px; min-height: 0; flex: 1; }
|
||||||
|
|
||||||
|
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; width: 34ch; flex: none; }
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--t-base) var(--ease-glide);
|
||||||
|
}
|
||||||
|
li:hover { background: var(--surface); }
|
||||||
|
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
|
||||||
|
.when { color: var(--fg); font-size: 13px; }
|
||||||
|
.muted { font-size: 11px; }
|
||||||
|
|
||||||
|
.diff {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--rim);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.row { white-space: pre-wrap; overflow-wrap: anywhere; padding: 0 10px; }
|
||||||
|
.row.gone { background: color-mix(in oklab, var(--alarm) 16%, transparent); }
|
||||||
|
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
|
||||||
|
.row.fold { color: var(--muted); }
|
||||||
|
|
||||||
|
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||||
|
footer .kbd { margin-right: 2px; }
|
||||||
|
footer .gap { width: 14px; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.split { flex-direction: column; }
|
||||||
|
ul { width: auto; max-height: 30%; }
|
||||||
|
footer { display: none; }
|
||||||
|
}
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
footer { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+33
-2
@@ -78,6 +78,21 @@ export interface SshKey {
|
|||||||
vault: boolean
|
vault: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a document said before a save replaced it. */
|
||||||
|
export interface RevisionSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
saved_at: number
|
||||||
|
/** `edit`, `force` (`:w!`), `sftp`, or `restore`. */
|
||||||
|
reason: string
|
||||||
|
/** Characters of text. */
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Revision extends Omit<RevisionSummary, 'size'> {
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Something deleted, waiting in the trash to be restored or purged. */
|
/** Something deleted, waiting in the trash to be restored or purged. */
|
||||||
export interface TrashItem {
|
export interface TrashItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -392,12 +407,28 @@ export const api = {
|
|||||||
created_at?: number
|
created_at?: number
|
||||||
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
||||||
|
|
||||||
/** `base_updated_at`: refused with 409 if the node was saved since then. */
|
/**
|
||||||
|
* `base_updated_at`: refused with 409 if the node was saved since then.
|
||||||
|
* `restore`: the text is a revision brought back, so what it replaces is
|
||||||
|
* kept as a revision whatever the last one was.
|
||||||
|
*/
|
||||||
updateNode: (
|
updateNode: (
|
||||||
id: number,
|
id: number,
|
||||||
input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number },
|
input: {
|
||||||
|
name: string
|
||||||
|
body: string
|
||||||
|
created_at?: number
|
||||||
|
has_board?: boolean
|
||||||
|
base_updated_at?: number
|
||||||
|
restore?: boolean
|
||||||
|
},
|
||||||
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
|
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
|
||||||
|
|
||||||
|
/** A document's earlier versions, the newest first. */
|
||||||
|
revisions: (id: number) => request<RevisionSummary[]>(`/nodes/${id}/revisions`),
|
||||||
|
|
||||||
|
revision: (id: number, rev: number) => request<Revision>(`/nodes/${id}/revisions/${rev}`),
|
||||||
|
|
||||||
/** Into the trash; `purged` when it was in the trash already and is now gone. */
|
/** Into the trash; `purged` when it was in the trash already and is now gone. */
|
||||||
deleteNode: (id: number) =>
|
deleteNode: (id: number) =>
|
||||||
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
|
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -776,6 +776,19 @@ export const commands: CommandSpec[] = [
|
|||||||
await workspace.guard(() => workspace.deleteSpace(space.id))
|
await workspace.guard(() => workspace.deleteSpace(space.id))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'history',
|
||||||
|
aliases: ['hist'],
|
||||||
|
group: 'documents',
|
||||||
|
help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)",
|
||||||
|
run: async () => {
|
||||||
|
if (!requireOpen()) return
|
||||||
|
await workspace.guard(async () => {
|
||||||
|
await workspace.loadHistory()
|
||||||
|
workspace.overlay = 'history'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'trash',
|
name: 'trash',
|
||||||
args: '[purge]',
|
args: '[purge]',
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/** One line of a diff: kept, only in the old text, or only in the new. */
|
||||||
|
export type DiffLine = { kind: 'same' | 'gone' | 'added'; text: string }
|
||||||
|
|
||||||
|
/** A run of unchanged lines too long to be worth showing, folded to a count. */
|
||||||
|
export type DiffRow = DiffLine | { kind: 'fold'; count: number }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Past this many lines in the part that differs, the table a longest common
|
||||||
|
* subsequence needs would be tens of megabytes; the change is shown as the
|
||||||
|
* old lines out and the new ones in instead. Documents are rarely near it.
|
||||||
|
*/
|
||||||
|
const MAX_CELLS = 4_000_000
|
||||||
|
|
||||||
|
/** Lines of `before` and `after`, matched up the way `diff` would. */
|
||||||
|
export function diffLines(before: string, after: string): DiffLine[] {
|
||||||
|
const a = before.split('\n')
|
||||||
|
const b = after.split('\n')
|
||||||
|
|
||||||
|
// What both start and end with needs no table.
|
||||||
|
let start = 0
|
||||||
|
while (start < a.length && start < b.length && a[start] === b[start]) start++
|
||||||
|
let end = 0
|
||||||
|
while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
|
||||||
|
|
||||||
|
const head = a.slice(0, start).map((text): DiffLine => ({ kind: 'same', text }))
|
||||||
|
const tail = a.slice(a.length - end).map((text): DiffLine => ({ kind: 'same', text }))
|
||||||
|
const x = a.slice(start, a.length - end)
|
||||||
|
const y = b.slice(start, b.length - end)
|
||||||
|
|
||||||
|
if (x.length * y.length > MAX_CELLS) {
|
||||||
|
return [
|
||||||
|
...head,
|
||||||
|
...x.map((text): DiffLine => ({ kind: 'gone', text })),
|
||||||
|
...y.map((text): DiffLine => ({ kind: 'added', text })),
|
||||||
|
...tail,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lcs[i][j]: the longest common subsequence of x[i..] and y[j..].
|
||||||
|
const width = y.length + 1
|
||||||
|
const lcs = new Uint32Array((x.length + 1) * width)
|
||||||
|
for (let i = x.length - 1; i >= 0; i--) {
|
||||||
|
for (let j = y.length - 1; j >= 0; j--) {
|
||||||
|
lcs[i * width + j] =
|
||||||
|
x[i] === y[j] ? lcs[(i + 1) * width + j + 1] + 1 : Math.max(lcs[(i + 1) * width + j], lcs[i * width + j + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const middle: DiffLine[] = []
|
||||||
|
let i = 0
|
||||||
|
let j = 0
|
||||||
|
while (i < x.length && j < y.length) {
|
||||||
|
if (x[i] === y[j]) {
|
||||||
|
middle.push({ kind: 'same', text: x[i] })
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
} else if (lcs[(i + 1) * width + j] >= lcs[i * width + j + 1]) {
|
||||||
|
middle.push({ kind: 'gone', text: x[i++] })
|
||||||
|
} else {
|
||||||
|
middle.push({ kind: 'added', text: y[j++] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < x.length) middle.push({ kind: 'gone', text: x[i++] })
|
||||||
|
while (j < y.length) middle.push({ kind: 'added', text: y[j++] })
|
||||||
|
|
||||||
|
return [...head, ...middle, ...tail]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The diff with only `context` unchanged lines kept around each change. */
|
||||||
|
export function folded(lines: DiffLine[], context = 3): DiffRow[] {
|
||||||
|
const near = new Uint8Array(lines.length)
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (line.kind === 'same') return
|
||||||
|
for (let k = Math.max(index - context, 0); k <= Math.min(index + context, lines.length - 1); k++) near[k] = 1
|
||||||
|
})
|
||||||
|
const rows: DiffRow[] = []
|
||||||
|
let skipped = 0
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (near[index]) {
|
||||||
|
if (skipped) rows.push({ kind: 'fold', count: skipped })
|
||||||
|
skipped = 0
|
||||||
|
rows.push(line)
|
||||||
|
} else skipped++
|
||||||
|
})
|
||||||
|
if (skipped) rows.push({ kind: 'fold', count: skipped })
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
type WorkspaceNode,
|
type WorkspaceNode,
|
||||||
type MediaFile,
|
type MediaFile,
|
||||||
type NodeSummary,
|
type NodeSummary,
|
||||||
|
type Revision,
|
||||||
|
type RevisionSummary,
|
||||||
type SpaceOverview,
|
type SpaceOverview,
|
||||||
type TrashItem,
|
type TrashItem,
|
||||||
type VaultStatus,
|
type VaultStatus,
|
||||||
} from './api'
|
} from './api'
|
||||||
import { formatBytes, isTouchDevice, sealedNote, type Heading } from './util'
|
import { formatBytes, formatStamp, isTouchDevice, sealedNote, type Heading } from './util'
|
||||||
|
|
||||||
export type Pane = 'list' | 'document'
|
export type Pane = 'list' | 'document'
|
||||||
/** The tree of documents, or the board of the space being browsed. */
|
/** The tree of documents, or the board of the space being browsed. */
|
||||||
@@ -279,7 +281,7 @@ class Workspace {
|
|||||||
pane = $state<Pane>('list')
|
pane = $state<Pane>('list')
|
||||||
flash = $state<Flash>(null)
|
flash = $state<Flash>(null)
|
||||||
|
|
||||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash'>('none')
|
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history'>('none')
|
||||||
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
|
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
|
||||||
outline = $state.raw<Heading[]>([])
|
outline = $state.raw<Heading[]>([])
|
||||||
outlineCursor = $state(0)
|
outlineCursor = $state(0)
|
||||||
@@ -311,6 +313,10 @@ class Workspace {
|
|||||||
trashCursor = $state(0)
|
trashCursor = $state(0)
|
||||||
/** The last thing deleted here, which `u` puts back. */
|
/** The last thing deleted here, which `u` puts back. */
|
||||||
lastDeleted = $state<number | null>(null)
|
lastDeleted = $state<number | null>(null)
|
||||||
|
/** `:history`: the open document's earlier versions, and the one shown. */
|
||||||
|
history = $state<RevisionSummary[]>([])
|
||||||
|
historyCursor = $state(0)
|
||||||
|
revision = $state.raw<Revision | null>(null)
|
||||||
overview = $state<SpaceOverview[]>([])
|
overview = $state<SpaceOverview[]>([])
|
||||||
overviewCursor = $state(0)
|
overviewCursor = $state(0)
|
||||||
|
|
||||||
@@ -961,7 +967,7 @@ class Workspace {
|
|||||||
* since it was opened here — another tab, the phone, the SFTP mount —
|
* since it was opened here — another tab, the phone, the SFTP mount —
|
||||||
* unless `force`, which is `:w!`.
|
* unless `force`, which is `:w!`.
|
||||||
*/
|
*/
|
||||||
async save({ quiet = false, force = false, tile = this.tile } = {}) {
|
async save({ quiet = false, force = false, restore = false, tile = this.tile } = {}) {
|
||||||
if (!tile.open) return
|
if (!tile.open) return
|
||||||
// Snapshot what goes over the wire: an autosave races the typing that
|
// Snapshot what goes over the wire: an autosave races the typing that
|
||||||
// triggered it, and anything typed since must stay marked unsaved.
|
// triggered it, and anything typed since must stay marked unsaved.
|
||||||
@@ -969,6 +975,7 @@ class Workspace {
|
|||||||
const saved = await api.updateNode(tile.open.id, {
|
const saved = await api.updateNode(tile.open.id, {
|
||||||
...sent,
|
...sent,
|
||||||
base_updated_at: force ? undefined : tile.open.updated_at,
|
base_updated_at: force ? undefined : tile.open.updated_at,
|
||||||
|
restore,
|
||||||
})
|
})
|
||||||
|
|
||||||
tile.open = saved
|
tile.open = saved
|
||||||
@@ -1048,6 +1055,47 @@ class Workspace {
|
|||||||
if (this.overlay === 'trash') await this.loadTrash()
|
if (this.overlay === 'trash') await this.loadTrash()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `:history`: the open document's earlier versions, the newest first. */
|
||||||
|
async loadHistory() {
|
||||||
|
const open = this.open
|
||||||
|
if (!open) return
|
||||||
|
// What is typed and not yet written is part of what a revision is
|
||||||
|
// compared with, and of what a restore must not lose.
|
||||||
|
await this.flushTile()
|
||||||
|
this.history = await api.revisions(open.id)
|
||||||
|
this.historyCursor = 0
|
||||||
|
this.revision = null
|
||||||
|
await this.showRevision(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the revision at `index`, for the overlay to compare with the draft. */
|
||||||
|
async showRevision(index: number) {
|
||||||
|
const open = this.open
|
||||||
|
const summary = this.history[index]
|
||||||
|
this.historyCursor = index
|
||||||
|
if (!open || !summary) return
|
||||||
|
const revision = await api.revision(open.id, summary.id)
|
||||||
|
// A later j may have moved on while this one was being read.
|
||||||
|
if (this.history[this.historyCursor]?.id === revision.id) this.revision = revision
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring a revision back: it becomes the document again, and what it
|
||||||
|
* replaces is kept as a revision of its own, so a restore is undone the
|
||||||
|
* same way. The editor reads the draft when it opens, so this leaves it.
|
||||||
|
*/
|
||||||
|
async restoreRevision(revision: Revision) {
|
||||||
|
const tile = this.tile
|
||||||
|
if (!tile.open) return
|
||||||
|
await this.flushTile(tile)
|
||||||
|
tile.editing = false
|
||||||
|
tile.draft = { ...tile.draft, name: revision.name, body: revision.body }
|
||||||
|
tile.dirty = true
|
||||||
|
await this.save({ quiet: true, restore: true, tile })
|
||||||
|
this.overlay = 'none'
|
||||||
|
this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`)
|
||||||
|
}
|
||||||
|
|
||||||
async loadTrash() {
|
async loadTrash() {
|
||||||
this.trash = await api.trash()
|
this.trash = await api.trash()
|
||||||
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
|
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
|
||||||
|
|||||||
Reference in new issue
Block a user