use anyhow::Result;
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
pub const PER_DESK: i64 = 4;
pub const EVERYWHERE: i64 = 8;
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
);
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,
UNIQUE(desk_id, slot)
);
CREATE INDEX IF NOT EXISTS panes_desk ON panes(desk_id, slot);
"#;
#[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 panes: Vec<Pane>,
}
#[derive(Clone, Debug, Serialize)]
pub struct Pane {
pub id: String,
pub slot: i64,
pub cwd: String,
pub cmd: String,
pub created_at: i64,
}
#[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,
NoRoomLeft,
NoSuchDesk,
}
pub fn list(conn: &Connection) -> Result<Vec<Desk>> {
let mut stmt =
conn.prepare("SELECT id, name, root, col, row, created_at 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 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 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 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,
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) -> Result<bool> {
let clamp = |f: f64| {
if f.is_finite() {
f.clamp(MIN_FRACTION, MAX_FRACTION)
} else {
0.5
}
};
Ok(conn.execute(
"UPDATE desks SET col = ?2, row = ?3 WHERE id = ?1",
params![id, clamp(col), clamp(row)],
)? > 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 total: i64 = tx.query_row("SELECT COUNT(*) FROM panes", [], |r| r.get(0))?;
if total >= EVERYWHERE {
return Ok(Opened::NoRoomLeft);
}
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,
}))
}
pub fn close_pane(conn: &Connection, id: &str) -> Result<bool> {
Ok(conn.execute("DELETE FROM panes WHERE id = ?1", params![id])? > 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, 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(5)?,
desk_name: r.get(6)?,
root: r.get(7)?,
})
},
)
.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 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 panes; DELETE FROM desks;")?;
Ok(())
}
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)?,
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)?,
})
}
#[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
}
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_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();
assert!(close_pane(&conn, &first).unwrap());
assert!(matches!(pane(&mut conn, d), Opened::Pane(p) if p.slot == 1));
assert_eq!(slots(&conn, d), vec![1, 2, 3, 4]);
}
#[test]
fn eight_panes_is_the_whole_of_it_however_many_desks_there_are() {
let mut conn = db();
let desks: Vec<i64> = (0..4)
.map(|_| create(&conn, "/p", None, 0).unwrap().id)
.collect();
for d in &desks[..2] {
for _ in 0..PER_DESK {
assert!(matches!(pane(&mut conn, *d), Opened::Pane(_)));
}
}
assert_eq!(panes_open(&conn).unwrap(), EVERYWHERE);
assert!(matches!(pane(&mut conn, desks[2]), Opened::NoRoomLeft));
assert!(delete(&conn, desks[0]).unwrap());
assert_eq!(panes_open(&conn).unwrap(), 4, "the cascade took its panes");
assert!(matches!(pane(&mut conn, desks[2]), Opened::Pane(_)));
}
#[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).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).unwrap());
assert_eq!(get(&conn, d).unwrap().unwrap().col, 0.5);
assert!(!layout(&conn, d + 99, 0.5, 0.5).unwrap());
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");
}
}