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 POS_COLUMN: &str = "ALTER TABLE desks ADD COLUMN pos INTEGER NOT NULL DEFAULT 0";
pub const KIND_COLUMNS: [&str; 2] = [
"ALTER TABLE desks ADD COLUMN kind TEXT NOT NULL DEFAULT 'terminal'",
"ALTER TABLE desks ADD COLUMN boards TEXT NOT NULL DEFAULT ''",
];
pub const SENT_BY_COLUMN: &str =
"ALTER TABLE desk_notes ADD COLUMN sent_by TEXT NOT NULL DEFAULT ''";
pub const RETIRE_STUDIO: &str = "UPDATE desks SET kind = 'terminal' WHERE kind = 'studio'";
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,
closed_at INTEGER NOT NULL DEFAULT 0,
left_off TEXT NOT NULL DEFAULT '',
left_off_at INTEGER NOT NULL DEFAULT 0,
left_off_by TEXT NOT NULL DEFAULT '',
left_off_about TEXT NOT NULL DEFAULT '',
left_off_pane TEXT NOT NULL DEFAULT '',
visited_at INTEGER NOT NULL DEFAULT 0,
parked_at INTEGER NOT NULL DEFAULT 0,
parked_next TEXT NOT NULL DEFAULT ''
);
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_keys (
desk_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
used_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (desk_id, name)
);
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 '',
done_evidence TEXT NOT NULL DEFAULT '',
done_pane TEXT NOT NULL DEFAULT '',
suggested_by TEXT NOT NULL DEFAULT '',
images TEXT NOT NULL DEFAULT '',
stage TEXT NOT NULL DEFAULT '',
stage_by TEXT NOT NULL DEFAULT '',
stage_doc TEXT NOT NULL DEFAULT '',
stage_at INTEGER NOT NULL DEFAULT 0,
stage_pane TEXT NOT NULL DEFAULT '',
stage_session 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,
#[serde(skip_serializing_if = "Option::is_none")]
pub left_off: Option<LeftOff>,
pub visited_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub parked: Option<Parked>,
pub keys: Vec<DeskKey>,
pub panes: Vec<Pane>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DeskKey {
pub desk_id: i64,
pub name: String,
pub provider: String,
pub created_at: i64,
pub used_at: i64,
}
pub const EVERY_DESK: i64 = 0;
#[derive(Clone, Debug, Default, PartialEq, Serialize, serde::Deserialize)]
pub struct Parked {
pub at: i64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub next: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Done {
pub desk_id: i64,
pub text: String,
pub at: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub by: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub commit: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub doc: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub evidence: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, serde::Deserialize)]
#[serde(default)]
pub struct LeftOff {
pub text: String,
pub at: i64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub by: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub about: String,
#[serde(skip)]
pub pane: String,
}
pub const LEFT_OFF_CHARS: usize = 200;
#[derive(Clone, Debug, Default, PartialEq, 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,
#[serde(skip_serializing_if = "String::is_empty")]
pub done_evidence: String,
#[serde(skip)]
pub done_pane: String,
#[serde(skip)]
pub done_at: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub suggested_by: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub sent_by: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub images: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub stage: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub stage_by: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub stage_doc: String,
#[serde(skip_serializing_if = "is_zero")]
pub stage_at: i64,
#[serde(skip_serializing_if = "String::is_empty")]
pub stage_pane: String,
#[serde(skip)]
pub stage_session: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub stage_panel: String,
#[serde(skip_serializing_if = "is_zero")]
pub thread: i64,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
pub const STAGES: [&str; 3] = ["read", "planned", "working"];
#[derive(Clone, Debug, Default)]
pub struct Mark {
pub stage: String,
pub by: String,
pub doc: String,
pub pane: String,
pub session: String,
}
pub const NOTE_IMAGES: &str = "note_images";
pub const IMAGES_PER_NOTE: usize = 6;
pub fn image_name_ok(name: &str) -> bool {
let Some((hash, ext)) = name.split_once('.') else {
return false;
};
hash.len() == 16
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& matches!(ext, "png" | "jpg" | "gif" | "webp")
}
#[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 resume: 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(&format!(
"SELECT {DESK_COLS} FROM desks WHERE closed_at = 0 ORDER BY pos, 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);
}
}
let mut stmt = conn.prepare(
"SELECT desk_id, name, provider, created_at, used_at FROM desk_keys ORDER BY name, desk_id DESC",
)?;
let all: Vec<DeskKey> = stmt
.query_map([], row_to_key)?
.collect::<rusqlite::Result<_>>()?;
if !all.is_empty() {
for d in &mut desks {
let id = d.id;
d.keys = pick_keys(
all.iter()
.filter(|k| k.desk_id == id || k.desk_id == EVERY_DESK)
.cloned(),
);
}
}
Ok(desks)
}
pub fn get(conn: &Connection, id: i64) -> Result<Option<Desk>> {
let Some(mut desk) = conn
.query_row(
&format!("SELECT {DESK_COLS} FROM desks WHERE id = ?1 AND closed_at = 0"),
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<_>>()?;
desk.keys = keys(conn, id)?;
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, pos)
VALUES(?1, ?2, 0.5, 0.5, ?3, (SELECT COALESCE(MAX(pos), 0) + 1 FROM desks))",
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,
left_off: None,
visited_at: 0,
parked: None,
keys: Vec::new(),
panes: Vec::new(),
})
}
pub fn reorder(conn: &mut Connection, ids: &[i64]) -> Result<bool> {
let tx = conn.transaction()?;
let mut open: Vec<i64> = tx
.prepare("SELECT id FROM desks WHERE closed_at = 0")?
.query_map([], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
let mut asked = ids.to_vec();
open.sort_unstable();
asked.sort_unstable();
if open != asked {
return Ok(false);
}
for (i, id) in ids.iter().enumerate() {
tx.execute(
"UPDATE desks SET pos = ?2 WHERE id = ?1",
params![id, i as i64 + 1],
)?;
}
tx.commit()?;
Ok(true)
}
pub fn visit(conn: &Connection, id: i64, now: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE desks SET visited_at = ?2 WHERE id = ?1 AND closed_at = 0 AND visited_at < ?2 - 60",
params![id, now],
)? > 0)
}
pub fn park(conn: &Connection, id: i64, to: Option<&Parked>) -> Result<Option<Option<Parked>>> {
let Some(desk) = get(conn, id)? else {
return Ok(None);
};
let (at, next) = match to {
Some(p) => (p.at, clip_to(&p.next, LEFT_OFF_CHARS)),
None => (0, String::new()),
};
conn.execute(
"UPDATE desks SET parked_at = ?2, parked_next = ?3 WHERE id = ?1",
params![id, at, next],
)?;
Ok(Some(desk.parked))
}
pub fn done_since(conn: &Connection, since: i64) -> Result<Vec<Done>> {
let mut stmt = conn.prepare(
"SELECT n.desk_id, n.text, n.done_at, n.done_by, n.done_commit, n.done_doc, n.done_evidence
FROM desk_notes n JOIN desks d ON d.id = n.desk_id
WHERE n.done_at >= ?1 AND n.done_at != 0 AND n.removed_at = 0 AND d.closed_at = 0
ORDER BY n.done_at, n.id",
)?;
let rows = stmt
.query_map(params![since], |r| {
Ok(Done {
desk_id: r.get(0)?,
text: r.get(1)?,
at: r.get(2)?,
by: r.get(3)?,
commit: r.get(4)?,
doc: r.get(5)?,
evidence: r.get(6)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
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 close(conn: &mut Connection, id: i64, now: i64) -> Result<Option<Vec<String>>> {
let tx = conn.transaction()?;
if tx.execute(
"UPDATE desks SET closed_at = ?2 WHERE id = ?1 AND closed_at = 0",
params![id, now],
)? == 0
{
return Ok(None);
}
let ids: Vec<String> = tx
.prepare("SELECT id FROM panes WHERE desk_id = ?1 ORDER BY slot")?
.query_map(params![id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
for p in &ids {
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![p, now],
)?;
}
tx.execute("DELETE FROM panes WHERE desk_id = ?1", params![id])?;
tx.commit()?;
Ok(Some(ids))
}
pub fn reopen(conn: &mut Connection, id: i64) -> Result<bool> {
let tx = conn.transaction()?;
let Some(at) = tx
.query_row(
"SELECT closed_at FROM desks WHERE id = ?1 AND closed_at != 0",
params![id],
|r| r.get::<_, i64>(0),
)
.optional()?
else {
return Ok(false);
};
tx.execute("UPDATE desks SET closed_at = 0 WHERE id = ?1", params![id])?;
let back: Vec<String> = tx
.prepare(
"SELECT id FROM panes_closed WHERE desk_id = ?1 AND closed_at = ?2
ORDER BY rowid LIMIT ?3",
)?
.query_map(params![id, at, PER_DESK], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
for (i, p) in back.iter().enumerate() {
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![p, i as i64 + 1],
)?;
tx.execute("DELETE FROM panes_closed WHERE id = ?1", params![p])?;
}
tx.commit()?;
Ok(true)
}
pub type ClosedDesk = (i64, String, String, i64);
pub fn closed_desks(conn: &Connection, limit: usize) -> Result<Vec<ClosedDesk>> {
let mut stmt = conn.prepare(
"SELECT id, name, root, closed_at FROM desks WHERE closed_at != 0 ORDER BY closed_at DESC LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
pub fn keys(conn: &Connection, desk_id: i64) -> Result<Vec<DeskKey>> {
let mut stmt = conn.prepare(
"SELECT desk_id, name, provider, created_at, used_at FROM desk_keys
WHERE desk_id IN (?1, 0) ORDER BY name, desk_id DESC",
)?;
let rows: Vec<DeskKey> = stmt
.query_map(params![desk_id], row_to_key)?
.collect::<rusqlite::Result<_>>()?;
Ok(pick_keys(rows))
}
fn row_to_key(r: &rusqlite::Row) -> rusqlite::Result<DeskKey> {
Ok(DeskKey {
desk_id: r.get(0)?,
name: r.get(1)?,
provider: r.get(2)?,
created_at: r.get(3)?,
used_at: r.get(4)?,
})
}
fn pick_keys(rows: impl IntoIterator<Item = DeskKey>) -> Vec<DeskKey> {
let mut out: Vec<DeskKey> = Vec::new();
for k in rows {
if out.last().is_some_and(|l| l.name == k.name) {
continue;
}
out.push(k);
}
out
}
pub fn add_key(
conn: &Connection,
desk_id: i64,
name: &str,
provider: &str,
now: i64,
) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO desk_keys(desk_id, name, provider, created_at, used_at) VALUES (?1, ?2, ?3, ?4, 0)",
params![desk_id, name, provider, now],
)?;
Ok(())
}
pub fn remove_key(conn: &Connection, desk_id: i64, name: &str) -> Result<bool> {
Ok(conn.execute(
"DELETE FROM desk_keys WHERE desk_id = ?1 AND name = ?2",
params![desk_id, name],
)? > 0)
}
pub fn touch_keys(conn: &Connection, keys: &[DeskKey], now: i64) -> Result<()> {
for k in keys {
conn.execute(
"UPDATE desk_keys SET used_at = ?3 WHERE desk_id = ?1 AND name = ?2",
params![k.desk_id, k.name, now],
)?;
}
Ok(())
}
pub fn prune_keys(conn: &Connection, before: i64, dry_run: bool) -> Result<Vec<(i64, String)>> {
let mut stmt = conn.prepare(
"SELECT k.desk_id, k.name FROM desk_keys k JOIN desks d ON d.id = k.desk_id
WHERE d.closed_at != 0 AND d.closed_at < ?1 ORDER BY k.desk_id, k.name",
)?;
let gone: Vec<(i64, String)> = stmt
.query_map(params![before], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
if !dry_run {
conn.execute(
"DELETE FROM desk_keys WHERE desk_id IN (SELECT id FROM desks WHERE closed_at != 0 AND closed_at < ?1)",
params![before],
)?;
}
Ok(gone)
}
pub fn prune_desks(conn: &Connection, before: i64, dry_run: bool) -> Result<Vec<(i64, String)>> {
let mut stmt = conn.prepare(
"SELECT id, name FROM desks WHERE closed_at != 0 AND closed_at < ?1 ORDER BY closed_at",
)?;
let gone: Vec<(i64, String)> = stmt
.query_map(params![before], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
if !dry_run {
conn.execute(
"DELETE FROM desks WHERE closed_at != 0 AND closed_at < ?1",
params![before],
)?;
}
Ok(gone)
}
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 AND closed_at = 0",
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(),
resume: 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))
}
#[cfg(test)]
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, done_evidence, suggested_by, images,
stage, stage_by, stage_doc, stage_at, stage_pane, stage_session, done_pane, sent_by, thread_id FROM desk_notes
WHERE desk_id = ?1 AND removed_at = 0
ORDER BY CASE WHEN done_at != 0 THEN 2 WHEN suggested_by != '' THEN 1 ELSE 0 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>> {
add_note_from(conn, desk_id, text, "", now)
}
pub fn add_note_from(
conn: &mut Connection,
desk_id: i64,
text: &str,
sent_by: &str,
now: i64,
) -> Result<Option<DeskNote>> {
let sent_by: String = sent_by.trim().chars().take(60).collect();
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 AND closed_at = 0",
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, sent_by) VALUES(?1, ?2, ?3, ?4)",
params![desk_id, text, now, sent_by],
)?;
let id = tx.last_insert_rowid();
tx.commit()?;
Ok(Some(DeskNote {
id,
text,
created_at: now,
sent_by,
..DeskNote::default()
}))
}
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 = '', done_evidence = '', done_pane = '', suggested_by = ''
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 evidence: String,
pub pane: 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 evidence_ok(url: &str) -> bool {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"));
url.len() <= 500
&& rest.is_some_and(|r| !r.is_empty() && !r.starts_with('/') && !r.starts_with('.'))
&& url.bytes().all(|b| b.is_ascii_graphic())
}
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()
};
let evidence = if evidence_ok(&tick.evidence) {
tick.evidence.clone()
} else {
String::new()
};
Ok(conn.execute(
"UPDATE desk_notes SET done_at = ?3, done_by = ?4, done_commit = ?5, done_doc = ?6, done_evidence = ?7, done_pane = ?8
WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0 AND done_at = 0 AND suggested_by = ''",
params![desk_id, id, now, by, commit, doc, evidence, tick.pane],
)? > 0)
}
pub fn mark_note(conn: &Connection, desk_id: i64, id: i64, mark: &Mark, now: i64) -> Result<bool> {
if !STAGES.contains(&mark.stage.as_str()) || (mark.stage == "planned" && !doc_ok(&mark.doc)) {
return Ok(false);
}
let by = if mark.by.trim().is_empty() {
"an agent"
} else {
mark.by.trim()
};
let by: String = by.chars().take(60).collect();
let doc = if doc_ok(&mark.doc) {
mark.doc.to_ascii_lowercase()
} else {
String::new()
};
let working = mark.stage == "working";
Ok(conn.execute(
"UPDATE desk_notes SET stage = ?3, stage_by = ?4, stage_doc = CASE WHEN ?5 != '' THEN ?5 ELSE stage_doc END,
stage_at = ?6, stage_pane = ?7, stage_session = ?8
WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0 AND done_at = 0 AND suggested_by = ''",
params![
desk_id,
id,
mark.stage,
by,
doc,
now,
if working { mark.pane.as_str() } else { "" },
if working { mark.session.as_str() } else { "" }
],
)? > 0)
}
pub fn settle_stage(n: &mut DeskNote, live: bool) {
if n.stage == "working" && !live {
n.stage = if n.stage_doc.is_empty() {
"read"
} else {
"planned"
}
.into();
}
}
pub const SUGGESTIONS_PER_DESK: i64 = 3;
#[derive(Debug, PartialEq)]
pub enum Suggested {
Note(Box<DeskNote>),
Full,
Empty,
NoSuchDesk,
}
pub fn suggest_note(
conn: &mut Connection,
desk_id: i64,
text: &str,
by: &str,
now: i64,
) -> Result<Suggested> {
suggest_note_from(conn, desk_id, text, by, "", now)
}
pub fn suggest_note_from(
conn: &mut Connection,
desk_id: i64,
text: &str,
by: &str,
sent_by: &str,
now: i64,
) -> Result<Suggested> {
let sent_by: String = sent_by.trim().chars().take(60).collect();
let text = clip(text);
if text.is_empty() {
return Ok(Suggested::Empty);
}
let by = if by.trim().is_empty() {
"an agent"
} else {
by.trim()
};
let by: String = by.chars().take(60).collect();
let tx = conn.transaction()?;
if tx
.query_row(
"SELECT 1 FROM desks WHERE id = ?1 AND closed_at = 0",
params![desk_id],
|_| Ok(()),
)
.optional()?
.is_none()
{
return Ok(Suggested::NoSuchDesk);
}
let (held, waiting): (i64, i64) = tx.query_row(
"SELECT COUNT(*), COALESCE(SUM(suggested_by != '' AND done_at = 0), 0) FROM desk_notes
WHERE desk_id = ?1 AND removed_at = 0",
params![desk_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if held >= NOTES_PER_DESK || waiting >= SUGGESTIONS_PER_DESK {
return Ok(Suggested::Full);
}
tx.execute(
"INSERT INTO desk_notes(desk_id, text, created_at, suggested_by, sent_by) VALUES(?1, ?2, ?3, ?4, ?5)",
params![desk_id, text, now, by, sent_by],
)?;
let id = tx.last_insert_rowid();
tx.commit()?;
Ok(Suggested::Note(Box::new(DeskNote {
id,
text,
created_at: now,
suggested_by: by,
sent_by,
..DeskNote::default()
})))
}
pub fn keep_note(conn: &Connection, desk_id: i64, id: i64) -> Result<bool> {
Ok(conn.execute(
"UPDATE desk_notes SET suggested_by = '' WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0 AND suggested_by != ''",
params![desk_id, id],
)? > 0)
}
pub fn set_left_off(
conn: &Connection,
desk_id: i64,
to: &LeftOff,
) -> Result<Option<Option<LeftOff>>> {
let text = clip_to(&to.text, LEFT_OFF_CHARS);
let by: String = to.by.trim().chars().take(60).collect();
let about = if doc_ok(&to.about) {
to.about.to_ascii_lowercase()
} else {
String::new()
};
let Some(desk) = get(conn, desk_id)? else {
return Ok(None);
};
let (at, by, about, pane) = if text.is_empty() {
(0, String::new(), String::new(), String::new())
} else {
(to.at, by, about, to.pane.clone())
};
conn.execute(
"UPDATE desks SET left_off = ?2, left_off_at = ?3, left_off_by = ?4, left_off_about = ?5, left_off_pane = ?6 WHERE id = ?1",
params![desk_id, text, at, by, about, pane],
)?;
Ok(Some(desk.left_off))
}
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 type RemovedNote = (i64, i64, String, String, i64);
pub type ClosedPane = (String, i64, String, String, i64);
pub fn removed_notes(conn: &Connection, limit: usize) -> Result<Vec<RemovedNote>> {
let mut stmt = conn.prepare(
"SELECT n.desk_id, n.id, d.name, n.text, n.removed_at FROM desk_notes n JOIN desks d ON d.id = n.desk_id
WHERE n.removed_at != 0 AND d.closed_at = 0 ORDER BY n.removed_at DESC, n.id DESC LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
pub fn removed_since(conn: &Connection, desk_id: i64, since: i64) -> Result<Vec<(i64, String)>> {
let mut stmt = conn.prepare(
"SELECT id, text FROM desk_notes WHERE desk_id = ?1 AND removed_at > ?2 ORDER BY removed_at, id",
)?;
let rows = stmt
.query_map(params![desk_id, since], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
pub fn closed_panes(conn: &Connection, limit: usize) -> Result<Vec<ClosedPane>> {
let mut stmt = conn.prepare(
"SELECT c.id, c.desk_id, d.name, COALESCE(NULLIF(c.name, ''), NULLIF(c.cmd, ''), 'shell'), c.closed_at
FROM panes_closed c JOIN desks d ON d.id = c.desk_id WHERE d.closed_at = 0 ORDER BY c.closed_at DESC LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
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 clip_to(text: &str, chars: usize) -> String {
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
match text.char_indices().nth(chars) {
Some((at, _)) => text[..at].trim_end().to_string(),
None => text,
}
}
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)?,
done_evidence: r.get(7)?,
suggested_by: r.get(8)?,
images: r
.get::<_, String>(9)?
.split_whitespace()
.map(String::from)
.collect(),
stage: r.get(10)?,
stage_by: r.get(11)?,
stage_doc: r.get(12)?,
stage_at: r.get(13)?,
stage_pane: r.get(14)?,
stage_session: r.get(15)?,
stage_panel: String::new(),
done_pane: r.get(16)?,
done_at: r.get(2)?,
sent_by: r.get(17)?,
thread: r.get(18)?,
})
}
pub fn add_note_image(
conn: &Connection,
desk_id: i64,
id: i64,
name: &str,
) -> Result<Option<Vec<String>>> {
let had: Option<String> = conn
.query_row(
"SELECT images FROM desk_notes WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0",
params![desk_id, id],
|r| r.get(0),
)
.optional()?;
let Some(had) = had else { return Ok(None) };
let mut list: Vec<String> = had.split_whitespace().map(String::from).collect();
if !list.iter().any(|n| n == name) {
list.push(name.to_string());
}
if !set_note_images(conn, desk_id, id, &list)? {
return Ok(None);
}
Ok(Some(list))
}
pub fn set_note_images(
conn: &Connection,
desk_id: i64,
id: i64,
images: &[String],
) -> Result<bool> {
if images.len() > IMAGES_PER_NOTE || !images.iter().all(|n| image_name_ok(n)) {
return Ok(false);
}
let mut seen = Vec::new();
for n in images {
if !seen.contains(n) {
seen.push(n.clone());
}
}
Ok(conn.execute(
"UPDATE desk_notes SET images = ?3 WHERE desk_id = ?1 AND id = ?2 AND removed_at = 0",
params![desk_id, id, seen.join(" ")],
)? > 0)
}
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 AND closed_at = 0",
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())
}
const DESK_COLS: &str =
"id, name, root, col, row, created_at, full_slot, left_off, left_off_at, left_off_by, left_off_about, visited_at, parked_at, parked_next, left_off_pane";
fn row_to_desk(r: &rusqlite::Row) -> rusqlite::Result<Desk> {
let text: String = r.get(7)?;
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)?,
left_off: if text.is_empty() {
None
} else {
Some(LeftOff {
text,
at: r.get(8)?,
by: r.get(9)?,
about: r.get(10)?,
pane: r.get(14)?,
})
},
visited_at: r.get(11)?,
parked: match r.get::<_, i64>(12)? {
0 => None,
at => Some(Parked {
at,
next: r.get(13)?,
}),
},
keys: Vec::new(),
panes: Vec::new(),
})
}
fn row_to_pane(r: &rusqlite::Row, at: usize) -> rusqlite::Result<Pane> {
let session: String = r.get(at + 5)?;
let resume = Some(session.as_str())
.filter(|s| valid_session(s))
.and_then(|s| crate::agents::resume_cmd(crate::agents::KEEPS_SESSIONS, s))
.unwrap_or_default();
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: session,
resume,
name: r.get(at + 6)?,
})
}
#[cfg(test)]
mod tests;