use super::*;
pub(crate) async fn home(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
let gated = refuse_desk(&app, &headers, &q).is_none();
let app2 = app.clone();
let (desks, days) = if gated {
tokio::task::spawn_blocking(move || home_desks(&app2))
.await
.unwrap_or((serde_json::Value::Null, serde_json::Value::Null))
} else {
(serde_json::Value::Null, serde_json::Value::Null)
};
let (turns, threads) = if gated {
app.store
.threads(|c, now| {
Ok((
crate::thread::waiting(c)?,
crate::thread::across_desks(c, now - THREADS_SHIPPED_SHOWN)?,
))
})
.map(|(t, th)| (json!(t), json!(th)))
.unwrap_or((serde_json::Value::Null, serde_json::Value::Null))
} else {
(serde_json::Value::Null, serde_json::Value::Null)
};
Json(json!({
"desks": desks,
"days": days,
"turns": turns,
"threads": threads,
"queue": app.store.queue(5).unwrap_or_default(),
"arrived": app.store.newest_unread(ARRIVED).unwrap_or_default(),
"waiting": waiting(&app),
"update": update_json(&app),
"agents": app.online(),
"quota": *app.quota.lock().unwrap_or_else(|e| e.into_inner()),
"version": VERSION,
}))
.into_response()
}
pub(crate) const THREADS_SHIPPED_SHOWN: i64 = 7 * 86_400;
pub(crate) const DAYS_SHOWN: i64 = 8;
pub(crate) const ARRIVED: usize = 5;
pub(crate) const HOME_NOTES: usize = 5;
pub(crate) fn home_desks(app: &App) -> (serde_json::Value, serde_json::Value) {
let now = crate::store::now();
let since = now - crate::git::WEEKS * 7 * 86_400;
let shown = now - DAYS_SHOWN * 86_400;
let list = app.store.desks().unwrap_or_default();
let done = app.store.desks_done_since(since).unwrap_or_default();
let sent = app.store.desks_sent_since(since).unwrap_or_default();
let mut days: Vec<serde_json::Value> = Vec::new();
let cards: Vec<serde_json::Value> = list
.iter()
.map(|d| {
let mut notes = app.store.desk_notes(d.id).unwrap_or_default();
settle_stages(app, &mut notes);
let open = notes.iter().filter(|n| !n.done && n.suggested_by.is_empty()).count();
let notes_done = notes.iter().filter(|n| n.done).count();
let suggested = notes.iter().filter(|n| !n.done && !n.suggested_by.is_empty()).count();
let next: Vec<serde_json::Value> = notes
.iter()
.filter(|n| !n.done && n.suggested_by.is_empty())
.take(HOME_NOTES)
.map(|n| json!({ "id": n.id, "text": n.text, "stage": n.stage, "stage_by": n.stage_by,
"stage_doc": n.stage_doc, "stage_panel": n.stage_panel,
"stage_busy": n.stage == "working" && app.panes.status(&n.stage_pane).agent == "working" }))
.collect();
let last_doc = app.store.desk_docs(d.id, 1, false).unwrap_or_default().into_iter().next();
let git = app.git.read(std::path::Path::new(&d.root), now);
let ticks: Vec<&crate::desk::Done> = done.iter().filter(|t| t.desk_id == d.id).collect();
let docs: Vec<&(i64, String, String, i64)> = sent.iter().filter(|x| x.0 == d.id).collect();
let commits: &[crate::git::Commit] = git.as_deref().map(|g| g.commits.as_slice()).unwrap_or(&[]);
for t in ticks.iter().filter(|t| t.at >= shown) {
days.push(json!({ "desk": d.id, "kind": "tick", "at": t.at, "text": t.text, "by": t.by,
"commit": t.commit, "doc": t.doc, "evidence": t.evidence }));
}
for x in docs.iter().filter(|x| x.3 >= shown) {
days.push(json!({ "desk": d.id, "kind": "doc", "at": x.3, "id": x.1, "text": x.2 }));
}
for c in commits.iter().filter(|c| c.at >= shown) {
days.push(json!({ "desk": d.id, "kind": "commit", "at": c.at, "hash": c.hash, "text": c.subject }));
}
if let Some(l) = d.left_off.as_ref().filter(|l| l.at >= shown) {
days.push(json!({ "desk": d.id, "kind": "left", "at": l.at, "text": l.text, "by": l.by }));
}
let mut pulse: Vec<i64> = ticks
.iter()
.map(|t| t.at)
.chain(docs.iter().map(|x| x.3))
.chain(commits.iter().map(|c| c.at))
.chain(d.left_off.iter().map(|l| l.at))
.filter(|&at| at >= since)
.map(|at| at - at.rem_euclid(3600))
.collect();
pulse.sort_unstable();
pulse.dedup();
let tick = ticks.last().map(|t| (t.at, json!({ "kind": "tick", "at": t.at, "text": t.text, "commit": t.commit })));
let doc = last_doc.as_ref().map(|x| (x.received_at, json!({ "kind": "doc", "at": x.received_at, "text": x.title, "id": x.id })));
let last = match (tick, doc) {
(Some(t), Some(x)) => Some(if t.0 >= x.0 { t.1 } else { x.1 }),
(t, x) => t.or(x).map(|p| p.1),
};
let touched = [
d.visited_at,
d.left_off.as_ref().map_or(0, |l| l.at),
last_doc.as_ref().map_or(0, |x| x.received_at),
ticks.last().map_or(0, |t| t.at),
git.as_deref().and_then(|g| g.last.as_ref()).map_or(0, |c| c.at),
]
.into_iter()
.max()
.unwrap_or(0);
let panes: Vec<serde_json::Value> = d
.panes
.iter()
.map(|p| {
let st = app.panes.status(&p.id);
json!({
"id": p.id, "slot": p.slot, "name": p.name,
"running": st.running, "agent": st.agent, "agent_since": st.agent_since,
"blocked": st.blocked, "blocked_since": st.blocked_since,
"title": st.title, "model": st.model,
"ctx_pct": st.ctx_pct, "ctx_used": st.ctx_used, "ctx_size": st.ctx_size,
})
})
.collect();
json!({
"id": d.id, "name": d.name, "root": d.root, "left_off": d.left_off,
"visited_at": d.visited_at, "parked": d.parked, "touched": touched,
"panes": panes, "open": open, "done": notes_done, "suggested": suggested, "next": next,
"last": last, "git": git.as_deref(), "pulse": pulse,
"keys": d.keys,
})
})
.collect();
days.sort_by_key(|r| r["at"].as_i64().unwrap_or(0));
(json!(cards), json!(days))
}
pub(crate) async fn desk_git(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(desk)) = app.store.desk(id) else {
return StatusCode::NOT_FOUND.into_response();
};
let app2 = app.clone();
let git = tokio::task::spawn_blocking(move || {
app2.git
.read(std::path::Path::new(&desk.root), crate::store::now())
})
.await
.ok()
.flatten();
Json(json!({ "remote": git.as_deref().and_then(|g| g.remote.clone()) })).into_response()
}
pub(crate) async fn visit_desk(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.visit_desk(id) {
Ok(_) => Json(json!({ "ok": true })).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct ParkBody {
#[serde(default)]
pub(crate) next: Option<String>,
}
pub(crate) async fn park_desk(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<ParkBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.park_desk(id, b.next.as_deref()) {
Ok(Some(was)) => {
desks_moved(&app);
Json(json!({ "ok": true, "was": was })).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct WeekBody {
pub(crate) title: String,
pub(crate) content: String,
}
pub(crate) async fn desk_week(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<WeekBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(desk)) = app.store.desk(id) else {
return StatusCode::NOT_FOUND.into_response();
};
let payload = Payload {
content: Some(b.content),
title: Some(b.title.chars().take(200).collect()),
workflow: Some("Your days".into()),
lang: Some("md".into()),
cwd: Some(desk.root),
origin: Some("home".into()),
sender: Some("snyvi".into()),
..Default::default()
};
match receive_and_emit(&app, payload).await {
Ok(received) => Json(json!({ "ok": true, "id": received.doc.id })).into_response(),
Err(no) => *no,
}
}
#[derive(Deserialize)]
pub(crate) struct NoteTextBody {
pub(crate) text: String,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct NoteEditBody {
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) done: Option<bool>,
}
#[derive(Deserialize)]
pub(crate) struct NewDeskBody {
#[serde(default)]
pub(crate) root: Option<String>,
#[serde(default)]
pub(crate) path: String,
#[serde(default)]
pub(crate) project: Option<i64>,
#[serde(default)]
pub(crate) name: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct LayoutBody {
pub(crate) col: f64,
pub(crate) row: f64,
#[serde(default)]
pub(crate) full: Option<i64>,
}
#[derive(Deserialize)]
pub(crate) struct MoveBody {
pub(crate) from: i64,
pub(crate) to: i64,
}
#[derive(Deserialize)]
pub(crate) struct NewPaneBody {
#[serde(default)]
pub(crate) cmd: Option<String>,
}
pub(crate) async fn desks(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match (app.store.desks(), app.store.panes_open()) {
(Ok(desks), Ok(panes)) => Json(json!({
"desks": with_status(&app, &desks),
"home": dirs::home_dir(),
"panes": panes,
"per_desk": crate::desk::PER_DESK,
}))
.into_response(),
(Err(e), _) | (_, Err(e)) => err(e),
}
}
pub(crate) fn with_status(app: &App, desks: &[crate::desk::Desk]) -> serde_json::Value {
let mut v = serde_json::to_value(desks).unwrap_or_default();
for d in v.as_array_mut().into_iter().flatten() {
for p in d["panes"].as_array_mut().into_iter().flatten() {
let id = p["id"].as_str().unwrap_or_default().to_string();
p["status"] = serde_json::to_value(app.panes.status(&id)).unwrap_or_default();
}
}
v
}
pub(crate) async fn create_desk(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NewDeskBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let (dir, fallback) = match &b.root {
Some(root) => match app.browse.resolve(root, &b.path) {
Ok(dir) => (dir, None),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "no such folder" })),
)
.into_response()
}
},
None if b.project.is_some() => match b.project.and_then(|id| app.store.project_root(id)) {
Some(root) => (std::path::PathBuf::from(root), None),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "no such project" })),
)
.into_response()
}
},
None => match dirs::home_dir() {
Some(home) => (home, Some("desk")),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "there is no home directory to start in" })),
)
.into_response()
}
},
};
if !dir.is_dir() {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a desk is rooted at a folder" })),
)
.into_response();
}
let name = b
.name
.as_deref()
.and_then(clean_name)
.or(fallback.map(String::from));
match app
.store
.create_desk(&dir.to_string_lossy(), name.as_deref())
{
Ok(desk) => {
desks_moved(&app);
(StatusCode::CREATED, Json(json!({ "desk": desk }))).into_response()
}
Err(e) => err(e),
}
}
pub(crate) async fn desk_docs(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let removed = app.store.desk_docs(id, 40, true).unwrap_or_default();
match app.store.desk_docs(id, 40, false) {
Ok(docs) => Json(json!({ "docs": docs, "removed": removed })).into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn remove_desk_doc(
State(app): S,
headers: HeaderMap,
Path((id, doc)): Path<(i64, String)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
desk_doc_off(&app, id, &doc, true)
}
pub(crate) async fn restore_desk_doc(
State(app): S,
headers: HeaderMap,
Path((id, doc)): Path<(i64, String)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
desk_doc_off(&app, id, &doc, false)
}
pub(crate) fn desk_doc_off(app: &App, id: i64, doc: &str, off: bool) -> Response {
match app.store.set_desk_doc_off(id, doc, off) {
Ok(true) => {
emit(app, "deskdocs", json!({ "desk": id }));
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn desk_notes(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.desk_notes(id) {
Ok(mut notes) => {
settle_stages(&app, &mut notes);
Json(json!({ "notes": notes })).into_response()
}
Err(e) => err(e),
}
}
pub(crate) fn settle_stages(app: &App, notes: &mut [crate::desk::DeskNote]) {
for n in notes.iter_mut().filter(|n| n.stage == "working") {
let s = app.panes.status(&n.stage_pane);
let pane = if s.running && !s.agent.is_empty() {
app.store.pane(&n.stage_pane).ok().flatten()
} else {
None
};
let pane = pane.filter(|p| p.pane.agent_session == n.stage_session);
if let Some(p) = &pane {
n.stage_panel = if p.pane.name.is_empty() {
format!("panel {}", p.pane.slot)
} else {
p.pane.name.clone()
};
}
crate::desk::settle_stage(n, pane.is_some());
}
}
pub(crate) async fn add_desk_note(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NoteTextBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.add_desk_note(id, &b.text) {
Ok(Some(note)) => {
notes_moved(&app, id);
(StatusCode::CREATED, Json(json!({ "note": note }))).into_response()
}
Ok(None) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("a desk keeps {} notes", crate::desk::NOTES_PER_DESK) })),
)
.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn set_desk_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NoteEditBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.set_desk_note(id, note, b.text.as_deref(), b.done) {
Ok(true) => {
notes_moved(&app, id);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn remove_desk_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.remove_desk_note(id, note) {
Ok(true) => {
notes_moved(&app, id);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn restore_desk_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.restore_desk_note(id, note) {
Ok(true) => {
notes_moved(&app, id);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn keep_desk_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.keep_desk_note(id, note) {
Ok(true) => {
notes_moved(&app, id);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) const NOTE_IMAGE_BYTES: usize = 8 * 1024 * 1024;
pub(crate) async fn add_note_image(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
body: axum::body::Bytes,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let ext = match headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
{
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(json!({ "error": "an image, as png, jpeg, gif or webp" })),
)
.into_response()
}
};
if body.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "an empty image" })),
)
.into_response();
}
let dir = app.paths.data_dir.join(crate::desk::NOTE_IMAGES);
let name = format!("{}.{ext}", &blake3::hash(&body).to_hex()[..16]);
let file = dir.join(&name);
if !file.exists() {
if let Err(e) = std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(&file, &body)) {
return err(anyhow::anyhow!("keeping the picture: {e}"));
}
}
match app.store.add_note_image(id, note, &name) {
Ok(Some(images)) => {
notes_moved(&app, id);
Json(json!({ "name": name, "images": images })).into_response()
}
Ok(None) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("a line holds {} pictures", crate::desk::IMAGES_PER_NOTE) })),
)
.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct NoteImagesBody {
pub(crate) images: Vec<String>,
}
pub(crate) async fn set_note_images(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NoteImagesBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.set_note_images(id, note, &b.images) {
Ok(true) => {
notes_moved(&app, id);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn note_image(
State(app): S,
headers: HeaderMap,
Path((_id, name)): Path<(i64, String)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
if !crate::desk::image_name_ok(&name) {
return StatusCode::NOT_FOUND.into_response();
}
let kind = match name.rsplit('.').next() {
Some("png") => "image/png",
Some("jpg") => "image/jpeg",
Some("gif") => "image/gif",
_ => "image/webp",
};
match std::fs::read(
app.paths
.data_dir
.join(crate::desk::NOTE_IMAGES)
.join(&name),
) {
Ok(bytes) => (
[
(header::CONTENT_TYPE, kind),
(
header::CACHE_CONTROL,
"private, max-age=31536000, immutable",
),
],
bytes,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub(crate) fn notes_moved(app: &App, desk: i64) {
emit(app, "desknotes", json!({ "desk": desk }));
}
pub(crate) async fn desk_left_off(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<crate::desk::LeftOff>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.set_left_off(id, &b) {
Ok(Some(was)) => {
desks_moved(&app);
Json(json!({ "ok": true, "was": was })).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn desk_keys(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.desk_keys(id) {
Ok(keys) => Json(json!({ "keys": keys })).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct KeyBody {
pub(crate) name: String,
pub(crate) value: String,
#[serde(default)]
pub(crate) provider: String,
#[serde(default)]
pub(crate) every: bool,
}
#[derive(Deserialize, Default)]
pub(crate) struct KeyWhere {
#[serde(default)]
pub(crate) every: bool,
}
pub(crate) async fn add_desk_key(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<KeyBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let name = b.name.trim().to_string();
if let Err(why) = crate::secrets::valid_name(&name) {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": why })),
)
.into_response();
}
let value = b.value.trim().to_string();
if value.is_empty() || value.len() > 4096 {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": "a key is one line, up to 4 KB" })),
)
.into_response();
}
if !matches!(app.store.desk(id), Ok(Some(_))) {
return StatusCode::NOT_FOUND.into_response();
}
let desk_id = if b.every { crate::desk::EVERY_DESK } else { id };
let secrets = app.secrets.clone();
let (n, v) = (name.clone(), value);
let kept = match tokio::task::spawn_blocking(move || secrets.keep(desk_id, &n, &v)).await {
Ok(Ok(kept)) => kept,
Ok(Err(e)) => return err(e),
Err(e) => return err(anyhow::anyhow!("keeping the key: {e}")),
};
if let Err(e) = app.store.add_desk_key(desk_id, &name, b.provider.trim()) {
return err(e);
}
desks_moved(&app);
Json(json!({ "ok": true, "kept": kept })).into_response()
}
pub(crate) async fn remove_desk_key(
State(app): S,
headers: HeaderMap,
Path((id, name)): Path<(i64, String)>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<KeyWhere>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let every = b.every;
let desk_id = if every { crate::desk::EVERY_DESK } else { id };
match app.store.remove_desk_key(desk_id, &name) {
Ok(true) => {
let secrets = app.secrets.clone();
let n = name.clone();
let _ = tokio::task::spawn_blocking(move || secrets.forget(desk_id, &n)).await;
desks_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) fn brief_on(app: &App) -> bool {
!off_flag(app, "brief").exists()
}
pub(crate) fn asides_on(app: &App) -> bool {
!off_flag(app, "asides").exists()
}
fn off_flag(app: &App, name: &str) -> std::path::PathBuf {
app.paths.config_dir.join(format!("{name}-off"))
}
fn set_switch(app: &App, name: &str, on: bool) -> std::io::Result<()> {
let flag = off_flag(app, name);
if on {
match std::fs::remove_file(&flag) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::create_dir_all(&app.paths.config_dir).and_then(|_| std::fs::write(&flag, b""))
}
}
#[derive(Deserialize)]
pub(crate) struct SwitchBody {
pub(crate) on: bool,
}
fn switch(
app: &App,
headers: &HeaderMap,
q: &std::collections::HashMap<String, String>,
name: &str,
on: fn(&App) -> bool,
to: Option<bool>,
) -> Response {
if let Some(no) = refuse_desk(app, headers, q) {
return no;
}
match to.map_or(Ok(()), |to| set_switch(app, name, to)) {
Ok(()) => Json(json!({ "on": on(app) })).into_response(),
Err(e) => err(e.into()),
}
}
pub(crate) async fn brief_setting(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
switch(&app, &headers, &q, "brief", brief_on, None)
}
pub(crate) async fn set_brief_setting(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<SwitchBody>,
) -> Response {
switch(&app, &headers, &q, "brief", brief_on, Some(b.on))
}
pub(crate) fn mod_on(app: &App) -> bool {
!off_flag(app, "claude-mod").exists() && crate::claude_mod::claude_has_mods()
}
pub(crate) async fn mod_setting(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
switch(&app, &headers, &q, "claude-mod", mod_on, None)
}
pub(crate) async fn set_mod_setting(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<SwitchBody>,
) -> Response {
switch(&app, &headers, &q, "claude-mod", mod_on, Some(b.on))
}
pub(crate) async fn asides_setting(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
switch(&app, &headers, &q, "asides", asides_on, None)
}
pub(crate) async fn set_asides_setting(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<SwitchBody>,
) -> Response {
switch(&app, &headers, &q, "asides", asides_on, Some(b.on))
}
pub(crate) async fn rename_desk(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<RenameBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Some(name) = clean_name(&b.name) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a name cannot be empty" })),
)
.into_response();
};
match app.store.rename_desk(id, &name) {
Ok(true) => {
desks_moved(&app);
Json(json!({ "ok": true, "name": name })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
pub(crate) struct OrderBody {
pub(crate) ids: Vec<i64>,
}
pub(crate) async fn order_desks(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<OrderBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.reorder_desks(&b.ids) {
Ok(true) => {
desks_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => (
StatusCode::CONFLICT,
Json(json!({ "error": "the desks changed; this is not the whole list of them" })),
)
.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn desk_layout(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<LayoutBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.set_desk_layout(id, b.col, b.row, b.full) {
Ok(true) => Json(json!({ "ok": true })).into_response(),
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn move_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<MoveBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.move_pane(id, b.from, b.to) {
Ok(true) => {
desks_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn delete_desk(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.close_desk(id) {
Ok(Some(panes)) => {
for p in &panes {
app.panes.forget(p);
}
desks_moved(&app);
Json(json!({ "ok": true, "restore": format!("/api/desks/{id}/reopen") }))
.into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn reopen_desk(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.reopen_desk(id) {
Ok(true) => {
desks_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn open_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NewPaneBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(desk)) = app.store.desk(id) else {
return StatusCode::NOT_FOUND.into_response();
};
let cmd = b.cmd.unwrap_or_default();
match app.store.open_pane(desk.id, &desk.root, cmd.trim()) {
Ok(crate::desk::Opened::Pane(pane)) => {
desks_moved(&app);
(StatusCode::CREATED, Json(json!({ "pane": pane }))).into_response()
}
Ok(crate::desk::Opened::DeskFull) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("this desk holds {}", crate::desk::PER_DESK), "full": "desk" })),
)
.into_response(),
Ok(crate::desk::Opened::NoSuchDesk) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn close_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.close_pane(&id) {
Ok(true) => {
app.panes.forget(&id);
desks_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn restore_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.restore_pane(&id) {
Ok(crate::desk::Restored::Pane(pane)) => {
desks_moved(&app);
Json(json!({ "pane": pane })).into_response()
}
Ok(crate::desk::Restored::DeskFull) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("this desk holds {}", crate::desk::PER_DESK), "full": "desk" })),
)
.into_response(),
Ok(crate::desk::Restored::Gone) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) async fn rename_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<RenameBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
renamed(&app, &id, &b.name)
}
pub(crate) fn renamed(app: &App, id: &str, name: &str) -> Response {
match app.store.rename_pane(id, name) {
Ok(true) => {
desks_moved(app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
pub(crate) fn desks_moved(app: &App) {
emit(app, "desks", json!({}));
}
#[derive(Deserialize)]
pub(crate) struct StartBody {
#[serde(default)]
pub(crate) cmd: Option<String>,
#[serde(default)]
pub(crate) resume: bool,
#[serde(default)]
pub(crate) marked: bool,
#[serde(default = "default_cols")]
pub(crate) cols: u16,
#[serde(default = "default_rows")]
pub(crate) rows: u16,
#[serde(default)]
pub(crate) accent: String,
}
pub(crate) fn default_cols() -> u16 {
80
}
pub(crate) fn default_rows() -> u16 {
24
}
pub(crate) async fn start_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<StartBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(placed)) = app.store.pane(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let lapsed = b.resume && b.marked && !app.panes.marked(&id);
let offer = lapsed && app.panes.offered(&id);
let cmd = if b.resume && !lapsed {
if placed.pane.resume.is_empty() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "this panel has no conversation to resume" })),
)
.into_response();
}
placed.pane.resume.clone()
} else {
let cmd = b.cmd.unwrap_or_else(|| placed.pane.cmd.clone());
if cmd.trim() != placed.pane.cmd {
let _ = app.store.set_pane_cmd(&id, &cmd);
}
cmd
};
let live = app.panes.get(&id);
let cwd = if std::path::Path::new(&placed.pane.cwd).is_dir() {
placed.pane.cwd.as_str()
} else {
placed.root.as_str()
};
let keys = app.store.desk_keys(placed.desk_id).unwrap_or_default();
let mut env: Vec<(String, String)> = if keys.is_empty() {
Vec::new()
} else {
let secrets = app.secrets.clone();
let wanted = keys.clone();
tokio::task::spawn_blocking(move || secrets.values(&wanted))
.await
.unwrap_or_default()
};
if !env.is_empty() {
let found: Vec<_> = keys
.iter()
.filter(|k| env.iter().any(|(n, _)| n == &k.name))
.cloned()
.collect();
let _ = app.store.touch_desk_keys(&found);
}
if mod_on(&app) {
env.push((
"CLAUDE_CODE_PLUGIN_DIRS".into(),
crate::claude_mod::plugin_dirs(&crate::claude_mod::dir(&app.paths)),
));
}
let start = crate::pane::Start {
cwd,
root: &placed.root,
cmd: &cmd,
desk: &placed.desk_name,
slot: placed.pane.slot,
cols: b.cols,
rows: b.rows,
accent: &b.accent,
offer,
env: &env,
};
match live.start(start, &app.panes) {
Ok(status) => Json(json!({ "status": status })).into_response(),
Err(e) => (
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
pub(crate) async fn stop_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(_)) = app.store.pane(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
app.panes.get(&id).stop();
Json(json!({ "ok": true })).into_response()
}
pub(crate) async fn paste_image(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
body: axum::body::Bytes,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(placed)) = app.store.pane(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let ext = match headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
{
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(json!({ "error": "an image, as png, jpeg, gif or webp" })),
)
.into_response()
}
};
if body.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "an empty paste" })),
)
.into_response();
}
let dir = app.paths.data_dir.join("pastes");
let name = format!("{}.{ext}", &blake3::hash(&body).to_hex()[..16]);
let file = dir.join(&name);
if let Err(e) = std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(&file, &body)) {
return err(anyhow::anyhow!("keeping the pasted image: {e}"));
}
let payload = Payload {
path: Some(file.to_string_lossy().to_string()),
title: Some(format!(
"Pasted into {} [{}]",
placed.desk_name, placed.pane.slot
)),
workflow: Some(format!("{} pastes", placed.desk_name)),
cwd: Some(placed.root.clone()),
origin: Some("paste".into()),
pane: Some(id.clone()),
..Default::default()
};
match receive_and_emit(&app, payload).await {
Ok(received) => Json(json!({ "id": received.doc.id, "path": file })).into_response(),
Err(no) => *no,
}
}
#[derive(Deserialize)]
pub(crate) struct ResolveBody {
pub(crate) word: String,
pub(crate) desk: Option<i64>,
pub(crate) pane: Option<String>,
pub(crate) doc: Option<String>,
pub(crate) root: Option<String>,
pub(crate) path: Option<String>,
#[serde(default)]
pub(crate) open: bool,
}
pub(crate) async fn resolve_path(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<ResolveBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let mut bases: Vec<std::path::PathBuf> = Vec::new();
if let (Some(id), Some(pane)) = (b.desk, b.pane.as_deref()) {
let Ok(Some(d)) = app.store.desk(id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(p) = d.panes.iter().find(|p| p.id == pane) else {
return StatusCode::NOT_FOUND.into_response();
};
let now = app.panes.status(&p.id).cwd;
bases.extend([now, p.cwd.clone(), d.root.clone()].map(std::path::PathBuf::from));
} else if let Some(id) = b.doc.as_deref() {
let Ok(Some(d)) = app.store.get(id) else {
return StatusCode::NOT_FOUND.into_response();
};
bases.extend(
d.source_path
.as_deref()
.and_then(|p| std::path::Path::new(p).parent().map(|d| d.to_path_buf())),
);
if let Some(o) = &d.desk {
if let Ok(Some(desk)) = app.store.desk(o.id) {
bases.push(desk.root.into());
}
}
bases.extend(
app.store
.project_root(d.project_id)
.map(std::path::PathBuf::from),
);
} else if let Some(id) = b.root.as_deref() {
let Some(root) = app.browse.get(id) else {
return StatusCode::NOT_FOUND.into_response();
};
bases.extend(
app.browse
.resolve(id, b.path.as_deref().unwrap_or(""))
.ok()
.and_then(dir_of),
);
bases.push(root.path.into());
}
bases.retain(|d| !d.as_os_str().is_empty() && d.is_dir());
bases.dedup();
let home = dirs::home_dir();
let Some(found) = crate::resolve::find(&b.word, &bases, home.as_deref()) else {
return StatusCode::NOT_FOUND.into_response();
};
let shown = crate::agents::tilde(&found.path);
let kind = if found.dir { "dir" } else { "file" };
if !b.open {
return Json(json!({ "kind": kind, "path": shown, "line": found.line })).into_response();
}
let roots: Vec<(String, std::path::PathBuf)> = app
.browse
.list()
.into_iter()
.map(|r| (r.id, r.path.into()))
.collect();
let (id, rel) = match crate::resolve::under(&found.path, &roots) {
Some((id, rel)) => (id.to_string(), rel),
None => {
let dir = bases
.iter()
.rev()
.filter_map(|b| b.canonicalize().ok())
.find(|b| found.path.starts_with(b))
.or_else(|| {
if found.dir {
Some(found.path.clone())
} else {
found.path.parent().map(|p| p.to_path_buf())
}
});
let Some(root) = dir.and_then(|d| app.browse.open(&d).ok()) else {
return StatusCode::NOT_FOUND.into_response();
};
emit(&app, "browse", json!({ "roots": app.browse.list() }));
let rel = found
.path
.strip_prefix(&root.path)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
(root.id, rel)
}
};
Json(json!({ "kind": kind, "path": shown, "line": found.line, "root": id, "rel": rel }))
.into_response()
}