wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! The workbench's layout: one for the machine.
//!
//! The workbench is one window over the whole workshop. Its editor groups hold
//! tabs from every worktree and every board at once, so the layout is the
//! machine's, not a worktree's. What a worktree keeps of its own — which of its
//! folders the explorer has open — stays at `/worktrees/<id>/workbench/state`.
//!
//! The layout's shape is the workbench's: the UI kit's workbench snapshot and
//! the state of the sidebars and the panel. It is kept as the JSON the page
//! sends and given back as it came; the page decides, on reading it back, what
//! of it it can still show.

use std::sync::Arc;

use axum::{extract::State, http::StatusCode, routing::get, Json, Router};
use rusqlite::OptionalExtension;

use super::sessions::ErrorResponse;
use crate::machine::MachineState;

type Refusal = (StatusCode, Json<ErrorResponse>);

fn refuse(status: StatusCode, error: impl std::fmt::Display) -> Refusal {
    (status, Json(ErrorResponse { error: format!("{error:#}") }))
}

pub fn routes() -> Router<Arc<MachineState>> {
    Router::new().route("/api/v1/workbench/layout", get(read).put(write))
}

/// The statement that reads the layout, shared with the tests so they check
/// the statement that runs.
pub const SELECT_LAYOUT: &str = "SELECT layout_json FROM workbench_layout WHERE id = 1";

/// Replace the layout. An upsert on the one row: there is only ever one.
pub const UPSERT_LAYOUT: &str = "INSERT INTO workbench_layout (id, layout_json, updated_at)
     VALUES (1, ?1, datetime('now'))
     ON CONFLICT(id) DO UPDATE SET
       layout_json = excluded.layout_json,
       updated_at = excluded.updated_at";

/// GET /api/v1/workbench/layout — the layout the workbench last saved, or 404
/// when it has never saved one (every machine starts that way).
async fn read(State(machine): State<Arc<MachineState>>) -> Result<Json<serde_json::Value>, Refusal> {
    let database = machine.database.clone();
    let held = tokio::task::spawn_blocking(move || -> Result<Option<String>, String> {
        let conn = database.get_connection().map_err(|e| format!("cannot open the workshop's database: {e}"))?;
        conn.query_row(SELECT_LAYOUT, [], |row| row.get::<_, String>(0))
            .optional()
            .map_err(|e| format!("cannot read the saved workbench layout: {e}"))
    })
    .await
    .map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?
    .map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    let Some(raw) = held else {
        return Err(refuse(StatusCode::NOT_FOUND, "no saved workbench layout"));
    };
    // A layout that will not parse is a REFUSAL, not an empty one: answering
    // with nothing would let the next save overwrite what could be recovered.
    let layout = serde_json::from_str(&raw)
        .map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, format!("the saved workbench layout is not readable: {e}")))?;
    Ok(Json(layout))
}

/// PUT /api/v1/workbench/layout — replace the layout with the one given, which
/// must be a JSON object.
async fn write(
    State(machine): State<Arc<MachineState>>,
    Json(layout): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Refusal> {
    if !layout.is_object() {
        return Err(refuse(StatusCode::BAD_REQUEST, "a workbench layout is a JSON object"));
    }
    let json = serde_json::to_string(&layout).map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    let database = machine.database.clone();
    tokio::task::spawn_blocking(move || -> Result<(), String> {
        let conn = database.get_connection().map_err(|e| format!("cannot open the workshop's database: {e}"))?;
        conn.execute(UPSERT_LAYOUT, [&json]).map_err(|e| format!("cannot save the workbench layout: {e}"))?;
        Ok(())
    })
    .await
    .map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?
    .map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    Ok(Json(layout))
}

#[cfg(test)]
#[path = "workbench_layout_tests.rs"]
mod tests;