use anyhow::Result;
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
pub const PER_DESK: i64 = 4;
pub const NOTE_CHARS: usize = 200;
pub const NOTES_PER_DESK: i64 = 200;
const MIN_FRACTION: f64 = 0.15;
const MAX_FRACTION: f64 = 0.85;
pub const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS desks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
root TEXT NOT NULL,
col REAL NOT NULL DEFAULT 0.5,
row REAL NOT NULL DEFAULT 0.5,
created_at INTEGER NOT NULL,
full_slot INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS panes (
id TEXT PRIMARY KEY,
desk_id INTEGER NOT NULL REFERENCES desks(id) ON DELETE CASCADE,
slot INTEGER NOT NULL,
cwd TEXT NOT NULL,
cmd TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
agent_session TEXT NOT NULL DEFAULT '',
resume_next INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
UNIQUE(desk_id, slot)
);
CREATE INDEX IF NOT EXISTS panes_desk ON panes(desk_id, slot);
CREATE TABLE IF NOT EXISTS panes_closed (
id TEXT PRIMARY KEY,
desk_id INTEGER NOT NULL REFERENCES desks(id) ON DELETE CASCADE,
cwd TEXT NOT NULL,
cmd TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
agent_session TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
closed_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS desk_notes (
id INTEGER PRIMARY KEY,
desk_id INTEGER NOT NULL REFERENCES desks(id) ON DELETE CASCADE,
text TEXT NOT NULL,
done_at INTEGER NOT NULL DEFAULT 0,
removed_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
done_by TEXT NOT NULL DEFAULT '',
done_commit TEXT NOT NULL DEFAULT '',
done_doc TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS desk_notes_desk ON desk_notes(desk_id, done_at, id);
"#;
#[derive(Clone, Debug, Serialize)]
pub struct Desk {
pub id: i64,
pub name: String,
pub root: String,
pub col: f64,
pub row: f64,
pub created_at: i64,
pub full_slot: i64,
pub panes: Vec<Pane>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DeskNote {
pub id: i64,
pub text: String,
pub done: bool,
pub created_at: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub done_by: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub done_commit: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub done_doc: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct Pane {
pub id: String,
pub slot: i64,
pub cwd: String,
pub cmd: String,
pub created_at: i64,
pub agent_session: String,
pub name: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Origin {
pub id: i64,
pub name: String,
pub slot: i64,
}
#[derive(Clone, Debug)]
pub struct Placed {
pub pane: Pane,
pub desk_id: i64,
pub desk_name: String,
pub root: String,
}
#[derive(Debug)]
pub enum Opened {
Pane(Pane),
DeskFull,
NoSuchDesk,
}
pub const NAME_CHARS: usize = 80;
pub fn list(conn: &Connection) -> Result<Vec<Desk>> {
let mut stmt = conn
.prepare("SELECT id, name, root, col, row, created_at, full_slot FROM desks ORDER BY id")?;
let mut desks: Vec<Desk> = stmt
.query_map([], row_to_desk)?
.collect::<rusqlite::Result<_>>()?;
let mut stmt = conn.prepare(
"SELECT desk_id, id, slot, cwd, cmd, created_at, agent_session, name FROM panes ORDER BY desk_id, slot",
)?;
let panes: Vec<(i64, Pane)> = stmt
.query_map([], |r| Ok((r.get(0)?, row_to_pane(r, 1)?)))?
.collect::<rusqlite::Result<_>>()?;
for (desk_id, pane) in panes {
if let Some(d) = desks.iter_mut().find(|d| d.id == desk_id) {
d.panes.push(pane);
}
}
Ok(desks)
}
pub fn get(conn: &Connection, id: i64) -> Result<Option<Desk>> {
let Some(mut desk) = conn
.query_row(
"SELECT id, name, root, col, row, created_at, full_slot FROM desks WHERE id = ?1",
params![id],
row_to_desk,
)
.optional()?
else {
return Ok(None);
};
let mut stmt = conn.prepare(
"SELECT id, slot, cwd, cmd, created_at, agent_session, name FROM panes WHERE desk_id = ?1 ORDER BY slot",
)?;
desk.panes = stmt
.query_map(params![id], |r| row_to_pane(r, 0))?
.collect::<rusqlite::Result<_>>()?;
Ok(Some(desk))
}
pub fn create(conn: &Connection, root: &str, name: Option<&str>, now: i64) -> Result<Desk> {
let wanted = name
.map(str::trim)
.filter(|n| !n.is_empty())
.map(str::to_string)
.unwrap_or_else(|| derive_name(root));
let name = free_name(conn, &wanted)?;
conn.execute(
"INSERT INTO desks(name, root, col, row, created_at) VALUES(?1, ?2, 0.5, 0.5, ?3)",
params![name, root, now],
)?;
let id = conn.last_insert_rowid();
Ok(Desk {
id,
name,
root: root.to_string(),
col: 0.5,
row: 0.5,
created_at: now,
full_slot: 0,
panes: Vec::new(),
})
}
pub fn rename(conn: &Connection, id: i64, name: &str) -> Result<bool> {
let name = name.trim();
if name.is_empty() {
return Ok(false);
}
Ok(conn.execute(
"UPDATE desks SET name = ?2 WHERE id = ?1",
params![id, name],
)? > 0)
}
pub fn layout(conn: &Connection, id: i64, col: f64, row: f64, full: Option<i64>) -> Result<bool> {
let clamp = |f: f64| {
if f.is_finite() {
f.clamp(MIN_FRACTION, MAX_FRACTION)
} else {
0.5
}
};
let full = full.map(|s| if (1..=PER_DESK).contains(&s) { s } else { 0 });
Ok(conn.execute(
"UPDATE desks SET col = ?2, row = ?3, full_slot = COALESCE(?4, full_slot) WHERE id = ?1",
params![id, clamp(col), clamp(row), full],
)? > 0)
}
pub fn delete(conn: &Connection, id: i64) -> Result<bool> {
Ok(conn.execute("DELETE FROM desks WHERE id = ?1", params![id])? > 0)
}
pub fn open_pane(
conn: &mut Connection,
desk_id: i64,
cwd: &str,
cmd: &str,
now: i64,
) -> Result<Opened> {
let tx = conn.transaction()?;
let exists: bool = tx
.query_row(
"SELECT 1 FROM desks WHERE id = ?1",
params![desk_id],
|_| Ok(()),
)
.optional()?
.is_some();
if !exists {
return Ok(Opened::NoSuchDesk);
}
let taken: Vec<i64> = tx
.prepare("SELECT slot FROM panes WHERE desk_id = ?1")?
.query_map(params![desk_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
let Some(slot) = (1..=PER_DESK).find(|s| !taken.contains(s)) else {
return Ok(Opened::DeskFull);
};
let id = pane_id()?;
tx.execute(
"INSERT INTO panes(id, desk_id, slot, cwd, cmd, created_at) VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
params![id, desk_id, slot, cwd, cmd, now],
)?;
tx.commit()?;
Ok(Opened::Pane(Pane {
id,
slot,
cwd: cwd.to_string(),
cmd: cmd.to_string(),
created_at: now,
agent_session: String::new(),
name: String::new(),
}))
}
pub fn move_pane(tx: &rusqlite::Transaction, desk_id: i64, from: i64, to: i64) -> Result<bool> {
let range = 1..=PER_DESK;
if !range.contains(&from) || !range.contains(&to) {
return Ok(false);
}
let moving: Option<String> = tx
.query_row(
"SELECT id FROM panes WHERE desk_id = ?1 AND slot = ?2",
params![desk_id, from],
|r| r.get(0),
)
.optional()?;
let Some(moving) = moving else {
return Ok(false);
};
if from == to {
return Ok(true);
}
tx.execute("UPDATE panes SET slot = 0 WHERE id = ?1", params![moving])?;
tx.execute(
"UPDATE panes SET slot = ?3 WHERE desk_id = ?1 AND slot = ?2",
params![desk_id, to, from],
)?;
tx.execute(
"UPDATE panes SET slot = ?2 WHERE id = ?1",
params![moving, to],
)?;
tx.execute(
"UPDATE desks SET full_slot = CASE full_slot WHEN ?2 THEN ?3 WHEN ?3 THEN ?2 ELSE full_slot END WHERE id = ?1",
params![desk_id, from, to],
)?;
Ok(true)
}
pub fn close_pane(tx: &rusqlite::Transaction, id: &str, now: i64) -> Result<Option<(i64, i64)>> {
let Some((desk_id, slot)) = tx
.query_row(
"SELECT desk_id, slot FROM panes WHERE id = ?1",
params![id],
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
)
.optional()?
else {
return Ok(None);
};
tx.execute(
"INSERT OR REPLACE INTO panes_closed(id, desk_id, cwd, cmd, name, agent_session, created_at, closed_at)
SELECT id, desk_id, cwd, cmd, name, agent_session, created_at, ?2 FROM panes WHERE id = ?1",
params![id, now],
)?;
tx.execute("DELETE FROM panes WHERE id = ?1", params![id])?;
for s in slot + 1..=PER_DESK {
tx.execute(
"UPDATE panes SET slot = ?3 WHERE desk_id = ?1 AND slot = ?2",
params![desk_id, s, s - 1],
)?;
}
tx.execute(
"UPDATE desks SET full_slot = CASE WHEN full_slot = ?2 THEN 0 WHEN full_slot > ?2 THEN full_slot - 1 ELSE full_slot END WHERE id = ?1",
params![desk_id, slot],
)?;
Ok(Some((desk_id, slot)))
}
#[derive(Debug)]
pub enum Restored {
Pane(Pane),
DeskFull,
Gone,
}
pub fn restore_pane(conn: &mut Connection, id: &str) -> Result<Restored> {
let tx = conn.transaction()?;
let Some(desk_id) = tx
.query_row(
"SELECT desk_id FROM panes_closed WHERE id = ?1",
params![id],
|r| r.get::<_, i64>(0),
)
.optional()?
else {
return Ok(Restored::Gone);
};
let taken: Vec<i64> = tx
.prepare("SELECT slot FROM panes WHERE desk_id = ?1")?
.query_map(params![desk_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
let Some(slot) = (1..=PER_DESK).find(|s| !taken.contains(s)) else {
return Ok(Restored::DeskFull);
};
tx.execute(
"INSERT INTO panes(id, desk_id, slot, cwd, cmd, name, agent_session, created_at)
SELECT id, desk_id, ?2, cwd, cmd, name, agent_session, created_at FROM panes_closed WHERE id = ?1",
params![id, slot],
)?;
tx.execute("DELETE FROM panes_closed WHERE id = ?1", params![id])?;
let pane = tx.query_row(
"SELECT id, slot, cwd, cmd, created_at, agent_session, name FROM panes WHERE id = ?1",
params![id],
|r| row_to_pane(r, 0),
)?;
tx.commit()?;
Ok(Restored::Pane(pane))
}
pub fn closed_on(conn: &Connection, desk_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare("SELECT id FROM panes_closed WHERE desk_id = ?1")?;
let ids = stmt
.query_map(params![desk_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
Ok(ids)
}
pub fn prune_closed(
conn: &Connection,
before: i64,
dry_run: bool,
) -> Result<Vec<(String, String)>> {
let mut stmt = conn.prepare(
"SELECT c.id, COALESCE(NULLIF(c.name, ''), NULLIF(c.cmd, ''), 'shell') || ' on ' || d.name
FROM panes_closed c JOIN desks d ON d.id = c.desk_id WHERE c.closed_at < ?1 ORDER BY c.closed_at",
)?;
let gone: Vec<(String, String)> = stmt
.query_map(params![before], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
if !dry_run {
conn.execute(
"DELETE FROM panes_closed WHERE closed_at < ?1",
params![before],
)?;
}
Ok(gone)
}
pub fn rename_pane(conn: &Connection, id: &str, name: &str) -> Result<bool> {
let name: String = name
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(NAME_CHARS)
.collect();
Ok(conn.execute(
"UPDATE panes SET name = ?2 WHERE id = ?1",
params![id, name],
)? > 0)
}
pub fn pane(conn: &Connection, id: &str) -> Result<Option<Placed>> {
Ok(conn
.query_row(
"SELECT p.id, p.slot, p.cwd, p.cmd, p.created_at, p.agent_session, p.name, d.id, d.name, d.root
FROM panes p JOIN desks d ON d.id = p.desk_id WHERE p.id = ?1",
params![id],
|r| {
Ok(Placed {
pane: row_to_pane(r, 0)?,
desk_id: r.get(7)?,
desk_name: r.get(8)?,
root: r.get(9)?,
})
},
)
.optional()?)
}
pub fn set_cmd(conn: &Connection, id: &str, cmd: &str) -> Result<bool> {
Ok(conn.execute(
"UPDATE panes SET cmd = ?2 WHERE id = ?1",
params![id, cmd.trim()],
)? > 0)
}
pub fn set_agent_session(conn: &Connection, id: &str, session: &str) -> Result<bool> {
if !valid_session(session) {
return Ok(false);
}
Ok(conn.execute(
"UPDATE panes SET agent_session = ?2 WHERE id = ?1 AND agent_session <> ?2",
params![id, session],
)? > 0)
}
pub fn mark_resume(conn: &Connection, ids: &[String]) -> Result<usize> {
conn.execute(
"UPDATE panes SET resume_next = 0 WHERE resume_next <> 0",
[],
)?;
let mut n = 0;
for id in ids {
n += conn.execute(
"UPDATE panes SET resume_next = 1 WHERE id = ?1 AND agent_session <> ''",
params![id],
)?;
}
Ok(n)
}
pub fn mark_offer(conn: &Connection, ids: &[String]) -> Result<usize> {
let mut n = 0;
for id in ids {
n += conn.execute(
"UPDATE panes SET resume_next = 2 WHERE id = ?1 AND resume_next = 0 AND agent_session <> ''",
params![id],
)?;
}
Ok(n)
}
pub fn read_resume(conn: &Connection) -> Result<(Vec<String>, Vec<String>)> {
let mut stmt =
conn.prepare("SELECT id, resume_next FROM panes WHERE resume_next <> 0 ORDER BY id")?;
let rows: Vec<(String, i64)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
let (planned, offered): (Vec<_>, Vec<_>) = rows.into_iter().partition(|(_, k)| *k == 1);
Ok((
planned.into_iter().map(|(id, _)| id).collect(),
offered.into_iter().map(|(id, _)| id).collect(),
))
}
pub fn clear_resume(conn: &Connection) -> Result<()> {
conn.execute(
"UPDATE panes SET resume_next = 0 WHERE resume_next <> 0",
[],
)?;
Ok(())
}
#[cfg(test)]
fn take_resume(conn: &Connection) -> Result<(Vec<String>, Vec<String>)> {
let marks = read_resume(conn)?;
clear_resume(conn)?;
Ok(marks)
}
pub fn set_cwd(conn: &Connection, id: &str, cwd: &str) -> Result<bool> {
Ok(conn.execute(
"UPDATE panes SET cwd = ?2 WHERE id = ?1 AND cwd <> ?2",
params![id, cwd],
)? > 0)
}
pub fn valid_session(s: &str) -> bool {
s.len() == 36
&& s.bytes().enumerate().all(|(i, b)| match i {
8 | 13 | 18 | 23 => b == b'-',
_ => b.is_ascii_digit() || (b'a'..=b'f').contains(&b),
})
}
pub fn panes_open(conn: &Connection) -> Result<i64> {
Ok(conn.query_row("SELECT COUNT(*) FROM panes", [], |r| r.get(0))?)
}
pub fn clear(conn: &Connection) -> Result<()> {
conn.execute_batch(
"DELETE FROM desk_notes; DELETE FROM panes_closed; DELETE FROM panes; DELETE FROM desks;",
)?;
Ok(())
}
pub fn notes(conn: &Connection, desk_id: i64) -> Result<Vec<DeskNote>> {
let mut stmt = conn.prepare(
"SELECT id, text, done_at, created_at, done_by, done_commit, done_doc FROM desk_notes
WHERE desk_id = ?1 AND removed_at = 0
ORDER BY CASE WHEN done_at = 0 THEN 0 ELSE 1 END, done_at, id",
)?;
let notes: Vec<DeskNote> = stmt
.query_map(params![desk_id], row_to_note)?
.collect::<rusqlite::Result<_>>()?;
Ok(notes)
}
pub fn add_note(
conn: &mut Connection,
desk_id: i64,
text: &str,
now: i64,
) -> Result<Option<DeskNote>> {
let text = clip(text);
if text.is_empty() {
return Ok(None);
}
let tx = conn.transaction()?;
let exists: bool = tx
.query_row(
"SELECT 1 FROM desks WHERE id = ?1",
params![desk_id],
|_| Ok(()),
)
.optional()?
.is_some();
if !exists {
return Ok(None);
}
let held: i64 = tx.query_row(
"SELECT COUNT(*) FROM desk_notes WHERE desk_id = ?1 AND removed_at = 0",
params![desk_id],
|r| r.get(0),
)?;
if held >= NOTES_PER_DESK {
return Ok(None);
}
tx.execute(
"INSERT INTO desk_notes(desk_id, text, created_at) VALUES(?1, ?2, ?3)",
params![desk_id, text, now],
)?;
let id = tx.last_insert_rowid();
tx.commit()?;
Ok(Some(DeskNote {
id,
text,
done: false,
created_at: now,
done_by: String::new(),
done_commit: String::new(),
done_doc: String::new(),
}))
}
pub fn set_note(
conn: &Connection,
desk_id: i64,
id: i64,
text: Option<&str>,
done: Option<bool>,
now: i64,
) -> Result<bool> {
if let Some(text) = text {
let text = clip(text);
if text.is_empty() {
return remove_note(conn, desk_id, id, now);
}
if conn.execute(
"UPDATE desk_notes SET text = ?3 WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0",
params![desk_id, id, text],
)? == 0
{
return Ok(false);
}
}
let Some(done) = done else {
return Ok(text.is_some());
};
Ok(conn.execute(
"UPDATE desk_notes SET done_at = ?3, done_by = '', done_commit = '', done_doc = ''
WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0",
params![desk_id, id, if done { now } else { 0 }],
)? > 0)
}
#[derive(Clone, Debug, Default)]
pub struct Tick {
pub by: String,
pub commit: String,
pub doc: String,
}
pub fn commit_ok(commit: &str) -> bool {
(7..=40).contains(&commit.len()) && commit.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn doc_ok(doc: &str) -> bool {
doc.len() == 10 && doc.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn tick_note(conn: &Connection, desk_id: i64, id: i64, tick: &Tick, now: i64) -> Result<bool> {
let by = if tick.by.trim().is_empty() {
"an agent"
} else {
tick.by.trim()
};
let by: String = by.chars().take(60).collect();
let commit = if commit_ok(&tick.commit) {
tick.commit.to_ascii_lowercase()
} else {
String::new()
};
let doc = if doc_ok(&tick.doc) {
tick.doc.to_ascii_lowercase()
} else {
String::new()
};
Ok(conn.execute(
"UPDATE desk_notes SET done_at = ?3, done_by = ?4, done_commit = ?5, done_doc = ?6
WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0 AND done_at = 0",
params![desk_id, id, now, by, commit, doc],
)? > 0)
}
pub fn remove_note(conn: &Connection, desk_id: i64, id: i64, now: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE desk_notes SET removed_at = ?3 WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0",
params![desk_id, id, now],
)? > 0)
}
pub fn restore_note(conn: &Connection, desk_id: i64, id: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE desk_notes SET removed_at = 0 WHERE desk_id = ?1 AND id = ?2",
params![desk_id, id],
)? > 0)
}
fn clip(text: &str) -> String {
let text = text.trim();
match text.char_indices().nth(NOTE_CHARS) {
Some((at, _)) => text[..at].trim_end().to_string(),
None => text.to_string(),
}
}
fn row_to_note(r: &rusqlite::Row) -> rusqlite::Result<DeskNote> {
Ok(DeskNote {
id: r.get(0)?,
text: r.get(1)?,
done: r.get::<_, i64>(2)? != 0,
created_at: r.get(3)?,
done_by: r.get(4)?,
done_commit: r.get(5)?,
done_doc: r.get(6)?,
})
}
fn derive_name(root: &str) -> String {
std::path::Path::new(root)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "desk".to_string())
}
fn free_name(conn: &Connection, wanted: &str) -> Result<String> {
let taken = |name: &str| -> Result<bool> {
Ok(conn
.query_row("SELECT 1 FROM desks WHERE name = ?1", params![name], |_| {
Ok(())
})
.optional()?
.is_some())
};
if !taken(wanted)? {
return Ok(wanted.to_string());
}
for n in 2.. {
let candidate = format!("{wanted} {n}");
if !taken(&candidate)? {
return Ok(candidate);
}
}
unreachable!("the range is unbounded")
}
fn pane_id() -> Result<String> {
let mut buf = [0u8; 16];
getrandom::fill(&mut buf)
.map_err(|e| anyhow::anyhow!("reading random bytes for a pane id: {e}"))?;
Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
}
fn row_to_desk(r: &rusqlite::Row) -> rusqlite::Result<Desk> {
Ok(Desk {
id: r.get(0)?,
name: r.get(1)?,
root: r.get(2)?,
col: r.get(3)?,
row: r.get(4)?,
created_at: r.get(5)?,
full_slot: r.get(6)?,
panes: Vec::new(),
})
}
fn row_to_pane(r: &rusqlite::Row, at: usize) -> rusqlite::Result<Pane> {
Ok(Pane {
id: r.get(at)?,
slot: r.get(at + 1)?,
cwd: r.get(at + 2)?,
cmd: r.get(at + 3)?,
created_at: r.get(at + 4)?,
agent_session: r.get(at + 5)?,
name: r.get(at + 6)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
conn.execute_batch(SCHEMA).unwrap();
conn
}
#[test]
fn a_list_reads_open_first_then_done_in_the_order_it_was_ticked() {
let mut conn = db();
let d = create(&conn, "/w", None, 0).unwrap().id;
for (i, text) in ["first", "second", "third"].iter().enumerate() {
add_note(&mut conn, d, text, i as i64).unwrap().unwrap();
}
let ids: Vec<i64> = notes(&conn, d).unwrap().iter().map(|n| n.id).collect();
set_note(&conn, d, ids[1], None, Some(true), 10).unwrap();
set_note(&conn, d, ids[0], None, Some(true), 20).unwrap();
let after = notes(&conn, d).unwrap();
assert_eq!(
after
.iter()
.map(|n| (n.text.as_str(), n.done))
.collect::<Vec<_>>(),
[("third", false), ("second", true), ("first", true)]
);
set_note(&conn, d, ids[0], None, Some(false), 30).unwrap();
assert_eq!(notes(&conn, d).unwrap()[0].text, "first");
}
#[test]
fn a_line_taken_off_is_kept_and_can_come_back() {
let mut conn = db();
let d = create(&conn, "/w", None, 0).unwrap().id;
let n = add_note(&mut conn, d, "wire up the route", 0)
.unwrap()
.unwrap();
assert!(remove_note(&conn, d, n.id, 1).unwrap());
assert!(notes(&conn, d).unwrap().is_empty());
let kept: i64 = conn
.query_row("SELECT COUNT(*) FROM desk_notes", [], |r| r.get(0))
.unwrap();
assert_eq!(kept, 1, "the row is kept, not deleted");
assert!(restore_note(&conn, d, n.id).unwrap());
assert_eq!(notes(&conn, d).unwrap()[0].text, "wire up the route");
assert!(remove_note(&conn, d, n.id, 2).unwrap());
assert!(!remove_note(&conn, d, n.id, 3).unwrap());
}
#[test]
fn a_note_is_reachable_only_through_its_own_desk() {
let mut conn = db();
let mine = create(&conn, "/mine", None, 0).unwrap().id;
let yours = create(&conn, "/yours", None, 0).unwrap().id;
let n = add_note(&mut conn, mine, "mine", 0).unwrap().unwrap();
assert!(!set_note(&conn, yours, n.id, Some("yours"), None, 1).unwrap());
assert!(!remove_note(&conn, yours, n.id, 1).unwrap());
assert_eq!(notes(&conn, mine).unwrap()[0].text, "mine");
assert!(notes(&conn, yours).unwrap().is_empty());
}
#[test]
fn an_agent_ticks_an_open_line_on_its_own_desk_and_nothing_else() {
let mut conn = db();
let mine = create(&conn, "/mine", None, 0).unwrap().id;
let yours = create(&conn, "/yours", None, 0).unwrap().id;
let n = add_note(&mut conn, mine, "wire the route", 0)
.unwrap()
.unwrap();
assert!(
!tick_note(&conn, yours, n.id, &by("claude-code"), 1).unwrap(),
"not across desks"
);
assert!(tick_note(&conn, mine, n.id, &by("claude-code"), 1).unwrap());
let got = ¬es(&conn, mine).unwrap()[0];
assert!(got.done);
assert_eq!(got.done_by, "claude-code");
assert!(
!tick_note(&conn, mine, n.id, &by("claude-code"), 2).unwrap(),
"a done line stays as it is"
);
assert!(set_note(&conn, mine, n.id, None, Some(false), 3).unwrap());
let got = ¬es(&conn, mine).unwrap()[0];
assert!(!got.done);
assert_eq!(got.done_by, "");
assert!(tick_note(&conn, mine, n.id, &by(" "), 4).unwrap());
assert_eq!(notes(&conn, mine).unwrap()[0].done_by, "an agent");
assert!(set_note(&conn, mine, n.id, None, Some(false), 5).unwrap());
assert!(remove_note(&conn, mine, n.id, 6).unwrap());
assert!(!tick_note(&conn, mine, n.id, &by("claude-code"), 7).unwrap());
}
fn by(name: &str) -> Tick {
Tick {
by: name.into(),
..Tick::default()
}
}
#[test]
fn a_tick_carries_its_commit_and_document_and_an_untick_clears_them() {
let mut conn = db();
let mine = create(&conn, "/mine", None, 0).unwrap().id;
let n = add_note(&mut conn, mine, "fix the hover", 0)
.unwrap()
.unwrap();
let tick = Tick {
by: "claude-code".into(),
commit: "90F09D6".into(),
doc: "82cc8f2d3c".into(),
};
assert!(tick_note(&conn, mine, n.id, &tick, 1).unwrap());
let got = ¬es(&conn, mine).unwrap()[0];
assert_eq!(
(got.done_commit.as_str(), got.done_doc.as_str()),
("90f09d6", "82cc8f2d3c")
);
assert!(set_note(&conn, mine, n.id, None, Some(false), 2).unwrap());
let got = ¬es(&conn, mine).unwrap()[0];
assert_eq!(
(
got.done_by.as_str(),
got.done_commit.as_str(),
got.done_doc.as_str()
),
("", "", "")
);
let junk = Tick {
by: "claude-code".into(),
commit: "main; rm -rf".into(),
doc: "../etc".into(),
};
assert!(tick_note(&conn, mine, n.id, &junk, 3).unwrap());
let got = ¬es(&conn, mine).unwrap()[0];
assert_eq!((got.done_commit.as_str(), got.done_doc.as_str()), ("", ""));
assert!(commit_ok("90f09d6") && commit_ok(&"a".repeat(40)));
assert!(!commit_ok("90f09d") && !commit_ok(&"a".repeat(41)) && !commit_ok("main"));
assert!(doc_ok("82cc8f2d3c") && !doc_ok("82cc8f2d3") && !doc_ok("82cc8f2d3z"));
}
#[test]
fn a_line_rewritten_to_nothing_comes_off_the_list() {
let mut conn = db();
let d = create(&conn, "/w", None, 0).unwrap().id;
let n = add_note(&mut conn, d, "something", 0).unwrap().unwrap();
assert!(set_note(&conn, d, n.id, Some(" "), None, 1).unwrap());
assert!(notes(&conn, d).unwrap().is_empty());
assert!(restore_note(&conn, d, n.id).unwrap());
assert_eq!(notes(&conn, d).unwrap()[0].text, "something");
}
#[test]
fn a_list_is_bounded_and_takes_no_empty_line() {
let mut conn = db();
let d = create(&conn, "/w", None, 0).unwrap().id;
assert!(add_note(&mut conn, d, " ", 0).unwrap().is_none());
assert!(add_note(&mut conn, d + 99, "nowhere", 0).unwrap().is_none());
for i in 0..NOTES_PER_DESK {
assert!(add_note(&mut conn, d, &format!("line {i}"), i)
.unwrap()
.is_some());
}
assert!(add_note(&mut conn, d, "one too many", 0).unwrap().is_none());
let first = notes(&conn, d).unwrap()[0].id;
remove_note(&conn, d, first, 1).unwrap();
assert!(add_note(&mut conn, d, "room now", 0).unwrap().is_some());
}
#[test]
fn a_long_line_is_cut_where_a_character_ends() {
let mut conn = db();
let d = create(&conn, "/w", None, 0).unwrap().id;
let long = "æ—¥".repeat(NOTE_CHARS + 50);
let n = add_note(&mut conn, d, &long, 0).unwrap().unwrap();
assert_eq!(n.text.chars().count(), NOTE_CHARS);
assert_eq!(notes(&conn, d).unwrap()[0].text, n.text);
}
#[test]
fn closing_a_desk_takes_its_list_with_it() {
let mut conn = db();
let d = create(&conn, "/w", None, 0).unwrap().id;
add_note(&mut conn, d, "goes with it", 0).unwrap().unwrap();
assert!(delete(&conn, d).unwrap());
let left: i64 = conn
.query_row("SELECT COUNT(*) FROM desk_notes", [], |r| r.get(0))
.unwrap();
assert_eq!(left, 0);
}
fn pane(conn: &mut Connection, desk: i64) -> Opened {
open_pane(conn, desk, "/p", "", 0).unwrap()
}
fn slots(conn: &Connection, desk: i64) -> Vec<i64> {
get(conn, desk)
.unwrap()
.unwrap()
.panes
.iter()
.map(|p| p.slot)
.collect()
}
#[test]
fn a_pane_keeps_the_last_conversation_and_only_a_uuid() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap();
let Opened::Pane(p) = pane(&mut conn, d.id) else {
panic!("no pane")
};
assert_eq!(p.agent_session, "");
let a = "0f6c1c2e-8a41-4b7e-9d3a-5e2f1b7c9a10";
let b = "a1b2c3d4-0000-4000-8000-123456789abc";
assert!(set_agent_session(&conn, &p.id, a).unwrap());
assert!(
!set_agent_session(&conn, &p.id, a).unwrap(),
"same id again"
);
assert_eq!(
super::pane(&conn, &p.id)
.unwrap()
.unwrap()
.pane
.agent_session,
a
);
assert!(set_agent_session(&conn, &p.id, b).unwrap());
assert_eq!(get(&conn, d.id).unwrap().unwrap().panes[0].agent_session, b);
for bad in [
"",
"; rm -rf ~",
"A1B2C3D4-0000-4000-8000-123456789ABC",
"a1b2c3d4-0000-4000-8000-123456789abc ",
"a1b2c3d4-0000-4000-8000-123456789ab$",
"a1b2c3d400004000800-0123456789abcde",
] {
assert!(!valid_session(bad), "{bad:?}");
assert!(!set_agent_session(&conn, &p.id, bad).unwrap());
}
assert!(!set_agent_session(&conn, "nope", a).unwrap());
let tx = conn.transaction().unwrap();
assert!(close_pane(&tx, &p.id, 1).unwrap().is_some());
tx.commit().unwrap();
assert!(super::pane(&conn, &p.id).unwrap().is_none());
}
#[test]
fn resume_marks_are_set_for_known_conversations_and_taken_once() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap();
let Opened::Pane(talked) = pane(&mut conn, d.id) else {
panic!("no pane")
};
let Opened::Pane(silent) = pane(&mut conn, d.id) else {
panic!("no pane")
};
let Opened::Pane(other) = pane(&mut conn, d.id) else {
panic!("no pane")
};
let a = "0f6c1c2e-8a41-4b7e-9d3a-5e2f1b7c9a10";
assert!(set_agent_session(&conn, &talked.id, a).unwrap());
assert!(set_agent_session(&conn, &other.id, a).unwrap());
let none = (Vec::<String>::new(), Vec::<String>::new());
assert_eq!(take_resume(&conn).unwrap(), none);
assert_eq!(
mark_resume(&conn, &[talked.id.clone(), silent.id.clone()]).unwrap(),
1
);
assert_eq!(take_resume(&conn).unwrap().0, vec![talked.id.clone()]);
assert_eq!(take_resume(&conn).unwrap(), none, "taken once");
mark_resume(&conn, std::slice::from_ref(&talked.id)).unwrap();
mark_resume(&conn, std::slice::from_ref(&other.id)).unwrap();
assert_eq!(take_resume(&conn).unwrap().0, vec![other.id.clone()]);
mark_resume(&conn, std::slice::from_ref(&talked.id)).unwrap();
assert_eq!(
mark_offer(
&conn,
&[talked.id.clone(), other.id.clone(), silent.id.clone()]
)
.unwrap(),
1
);
assert_eq!(
take_resume(&conn).unwrap(),
(vec![talked.id.clone()], vec![other.id.clone()])
);
}
#[test]
fn a_pane_keeps_the_folder_its_shell_moved_to() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap();
let Opened::Pane(p) = pane(&mut conn, d.id) else {
panic!()
};
assert!(set_cwd(&conn, &p.id, "/p/sub").unwrap());
assert!(!set_cwd(&conn, &p.id, "/p/sub").unwrap());
assert_eq!(
super::pane(&conn, &p.id).unwrap().unwrap().pane.cwd,
"/p/sub"
);
assert!(!set_cwd(&conn, "nope", "/x").unwrap());
}
#[test]
fn a_desk_is_named_after_its_folder_and_the_second_one_is_numbered() {
let conn = db();
let a = create(&conn, "/home/p/snyvi", None, 0).unwrap();
let b = create(&conn, "/home/p/snyvi", None, 0).unwrap();
let c = create(&conn, "/home/p/snyvi", None, 0).unwrap();
assert_eq!(
(a.name.as_str(), b.name.as_str(), c.name.as_str()),
("snyvi", "snyvi 2", "snyvi 3")
);
assert_ne!(a.id, b.id);
assert_eq!(b.root, "/home/p/snyvi", "same folder, separate instance");
let d = create(&conn, "/home/p/snyvi", Some("chores"), 0).unwrap();
assert_eq!(d.name, "chores");
assert!(delete(&conn, b.id).unwrap());
assert_eq!(
create(&conn, "/home/p/snyvi", None, 0).unwrap().name,
"snyvi 2"
);
}
#[test]
fn panes_fill_the_lowest_free_slot_and_the_fifth_is_refused() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap().id;
for _ in 0..PER_DESK {
assert!(matches!(pane(&mut conn, d), Opened::Pane(_)));
}
assert_eq!(slots(&conn, d), vec![1, 2, 3, 4]);
assert!(matches!(pane(&mut conn, d), Opened::DeskFull));
let first = get(&conn, d).unwrap().unwrap().panes[0].id.clone();
let tx = conn.transaction().unwrap();
assert_eq!(close_pane(&tx, &first, 1).unwrap(), Some((d, 1)));
tx.commit().unwrap();
assert_eq!(slots(&conn, d), vec![1, 2, 3]);
assert!(matches!(pane(&mut conn, d), Opened::Pane(p) if p.slot == 4));
assert_eq!(slots(&conn, d), vec![1, 2, 3, 4]);
}
#[test]
fn a_pane_moves_to_another_slot_and_the_one_there_takes_its_place() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap().id;
let other = create(&conn, "/q", None, 0).unwrap().id;
for _ in 0..3 {
pane(&mut conn, d);
}
pane(&mut conn, other);
let ids = |conn: &Connection, d| -> Vec<String> {
get(conn, d)
.unwrap()
.unwrap()
.panes
.iter()
.map(|p| p.id.clone())
.collect()
};
let before = ids(&conn, d);
assert!(layout(&conn, d, 0.5, 0.5, Some(1)).unwrap());
assert!(layout(&conn, other, 0.5, 0.5, Some(1)).unwrap());
let tx = conn.transaction().unwrap();
assert!(move_pane(&tx, d, 1, 3).unwrap());
tx.commit().unwrap();
assert_eq!(
ids(&conn, d),
[before[2].clone(), before[1].clone(), before[0].clone()]
);
assert_eq!(
get(&conn, d).unwrap().unwrap().full_slot,
3,
"full view went with its pane"
);
assert_eq!(get(&conn, other).unwrap().unwrap().full_slot, 1);
let tx = conn.transaction().unwrap();
assert!(move_pane(&tx, d, 2, 4).unwrap());
tx.commit().unwrap();
assert_eq!(slots(&conn, d), vec![1, 3, 4]);
let tx = conn.transaction().unwrap();
assert!(!move_pane(&tx, d, 2, 1).unwrap());
assert!(!move_pane(&tx, d, 1, 5).unwrap());
assert!(!move_pane(&tx, d, 0, 1).unwrap());
tx.commit().unwrap();
assert_eq!(slots(&conn, d), vec![1, 3, 4]);
assert_eq!(slots(&conn, other), vec![1], "the other desk is untouched");
}
#[test]
fn twelve_panes_on_three_desks_all_open() {
let mut conn = db();
let desks: Vec<i64> = (0..3)
.map(|_| create(&conn, "/p", None, 0).unwrap().id)
.collect();
for d in &desks {
for _ in 0..PER_DESK {
assert!(matches!(pane(&mut conn, *d), Opened::Pane(_)));
}
assert!(matches!(pane(&mut conn, *d), Opened::DeskFull));
}
assert_eq!(panes_open(&conn).unwrap(), 3 * PER_DESK);
assert!(delete(&conn, desks[0]).unwrap());
assert_eq!(
panes_open(&conn).unwrap(),
2 * PER_DESK,
"the cascade took its panes"
);
}
#[test]
fn a_pane_id_is_random_hex_and_a_desk_id_is_not() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap();
assert_eq!(d.id, 1, "a desk is an integer, like a project");
let Opened::Pane(a) = pane(&mut conn, d.id) else {
panic!("a pane on an empty desk")
};
let Opened::Pane(b) = pane(&mut conn, d.id) else {
panic!("a second pane")
};
assert_eq!(a.id.len(), 32);
assert!(a.id.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a.id, b.id);
}
#[test]
fn divider_fractions_are_clamped_and_a_missing_desk_says_so() {
let conn = db();
let d = create(&conn, "/p", None, 0).unwrap().id;
assert!(layout(&conn, d, 0.0, 2.5, None).unwrap());
let after = get(&conn, d).unwrap().unwrap();
assert_eq!((after.col, after.row), (MIN_FRACTION, MAX_FRACTION));
assert!(layout(&conn, d, f64::NAN, 0.4, None).unwrap());
assert_eq!(get(&conn, d).unwrap().unwrap().col, 0.5);
assert!(!layout(&conn, d + 99, 0.5, 0.5, None).unwrap());
assert!(layout(&conn, d, 0.5, 0.5, Some(3)).unwrap());
assert!(layout(&conn, d, 0.5, 0.5, None).unwrap());
assert_eq!(get(&conn, d).unwrap().unwrap().full_slot, 3);
assert!(layout(&conn, d, 0.5, 0.5, Some(9)).unwrap());
assert_eq!(get(&conn, d).unwrap().unwrap().full_slot, 0);
assert!(!rename(&conn, d, " ").unwrap(), "a name is not whitespace");
assert!(rename(&conn, d, "chores").unwrap());
assert_eq!(get(&conn, d).unwrap().unwrap().name, "chores");
}
#[test]
fn a_pane_on_no_desk_is_refused_without_inventing_one() {
let mut conn = db();
assert!(matches!(pane(&mut conn, 7), Opened::NoSuchDesk));
assert_eq!(panes_open(&conn).unwrap(), 0);
assert!(list(&conn).unwrap().is_empty());
}
#[test]
fn the_list_carries_empty_desks_and_their_panes_in_slot_order() {
let mut conn = db();
let a = create(&conn, "/p/one", None, 0).unwrap().id;
let b = create(&conn, "/p/two", None, 0).unwrap().id;
let _ = pane(&mut conn, b);
let _ = pane(&mut conn, b);
let listed = list(&conn).unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].id, a);
assert!(listed[0].panes.is_empty());
assert_eq!(
listed[1].panes.iter().map(|p| p.slot).collect::<Vec<_>>(),
vec![1, 2]
);
assert_eq!(listed[1].panes[0].cwd, "/p");
}
#[test]
fn a_closed_pane_leaves_no_gap_and_comes_back_at_the_end() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap().id;
for _ in 0..3 {
pane(&mut conn, d);
}
let ids: Vec<String> = get(&conn, d)
.unwrap()
.unwrap()
.panes
.iter()
.map(|p| p.id.clone())
.collect();
assert!(rename_pane(&conn, &ids[1], " the tests \n").unwrap());
assert!(layout(&conn, d, 0.5, 0.5, Some(3)).unwrap());
let tx = conn.transaction().unwrap();
assert_eq!(close_pane(&tx, &ids[1], 5).unwrap(), Some((d, 2)));
assert_eq!(close_pane(&tx, "nope", 5).unwrap(), None);
tx.commit().unwrap();
let after = get(&conn, d).unwrap().unwrap();
assert_eq!(
after
.panes
.iter()
.map(|p| (p.id.clone(), p.slot))
.collect::<Vec<_>>(),
[(ids[0].clone(), 1), (ids[2].clone(), 2)]
);
assert_eq!(after.full_slot, 2, "full view follows the pane it was on");
let Restored::Pane(p) = restore_pane(&mut conn, &ids[1]).unwrap() else {
panic!("not restored")
};
assert_eq!((p.slot, p.name.as_str()), (3, "the tests"));
assert!(
matches!(restore_pane(&mut conn, &ids[1]).unwrap(), Restored::Gone),
"twice is not twice"
);
let tx = conn.transaction().unwrap();
close_pane(&tx, &ids[2], 6).unwrap();
tx.commit().unwrap();
assert_eq!(get(&conn, d).unwrap().unwrap().full_slot, 0);
}
#[test]
fn a_closed_pane_does_not_come_back_to_a_full_desk() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap().id;
let Opened::Pane(first) = pane(&mut conn, d) else {
panic!()
};
let tx = conn.transaction().unwrap();
close_pane(&tx, &first.id, 1).unwrap();
tx.commit().unwrap();
for _ in 0..PER_DESK {
pane(&mut conn, d);
}
assert!(matches!(
restore_pane(&mut conn, &first.id).unwrap(),
Restored::DeskFull
));
assert_eq!(closed_on(&conn, d).unwrap(), vec![first.id.clone()]);
}
#[test]
fn closed_panes_last_until_prune_or_their_desk() {
let mut conn = db();
let d = create(&conn, "/p", None, 0).unwrap().id;
let (Opened::Pane(a), Opened::Pane(b)) = (pane(&mut conn, d), pane(&mut conn, d)) else {
panic!()
};
let tx = conn.transaction().unwrap();
close_pane(&tx, &a.id, 10).unwrap();
close_pane(&tx, &b.id, 20).unwrap();
tx.commit().unwrap();
let would = prune_closed(&conn, 15, true).unwrap();
assert_eq!(
would.iter().map(|g| g.0.clone()).collect::<Vec<_>>(),
vec![a.id.clone()]
);
assert_eq!(
closed_on(&conn, d).unwrap().len(),
2,
"a dry run removes nothing"
);
assert_eq!(prune_closed(&conn, 15, false).unwrap().len(), 1);
assert_eq!(closed_on(&conn, d).unwrap(), vec![b.id.clone()]);
assert!(delete(&conn, d).unwrap());
let left: i64 = conn
.query_row("SELECT COUNT(*) FROM panes_closed", [], |r| r.get(0))
.unwrap();
assert_eq!(left, 0, "the desk's delete cascades");
}
}