wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
use wsb::prelude::*;
use axum::{extract::State, http::StatusCode, Json};
use rusqlite::OptionalExtension;

use super::sessions::ErrorResponse;
use crate::web::state::WebState;

/// What a worktree keeps of its own in the workbench: which of its folders the
/// explorer has open. Everything else — the groups and tabs, the sidebars, the
/// panel — is the machine's one layout (`/api/v1/workbench/layout`), because
/// the workbench shows every worktree in one window.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorktreeState {
    pub expanded_paths: Vec<String>,
}

/// Read one worktree's saved state from the workshop's database.
///
/// The statement, not the handler, because the handler needs a whole `WebState`
/// to get at — and a test that spelled this out a second time would keep
/// passing while the handler drifted away from it.
pub const SELECT_LAYOUT: &str = "SELECT state_json FROM workbench_state WHERE worktree = ?1";

/// Write one worktree's state, replacing whatever was there.
///
/// An upsert rather than an insert: the worktree is the key, so a plain
/// insert would fail on every save after the first and the layout would
/// freeze at whatever was open the first time this daemon ran.
pub const UPSERT_LAYOUT: &str = "INSERT INTO workbench_state (worktree, state_json, updated_at)
     VALUES (?1, ?2, datetime('now'))
     ON CONFLICT(worktree) DO UPDATE SET
       state_json = excluded.state_json,
       updated_at = excluded.updated_at";

/// The folders this worktree's explorer left open, or 404 if it has never
/// saved them.
///
/// Keyed by the worktree: one daemon serves several, and the folders open in
/// one are its own.
pub async fn get_workbench_state(
    State(state): State<WebState>,
) -> Result<Json<WorktreeState>, (StatusCode, Json<ErrorResponse>)> {
    let service = state.db_service.clone();
    let key = state.worktree_key.clone();

    let held: Option<String> = tokio::task::spawn_blocking(move || {
        let conn = service
            .get_connection()
            .map_err(|e| format!("cannot open the workshop's database: {e}"))?;
        conn.query_row(SELECT_LAYOUT, [&key], |row| row.get::<_, String>(0))
        .optional()
        .map_err(|e| format!("cannot read the saved workbench layout: {e}"))
    })
    .await
    .map_err(|e| internal_error(format!("get_workbench_state task panicked: {e}")))?
    .map_err(internal_error)?;

    let Some(raw) = held else {
        // Never saved here. Not an error: every worktree starts this way, and
        // the frontend opens its defaults.
        return Err((
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: "no saved explorer state".to_string(),
            }),
        ));
    };

    // A row that will not parse is a REFUSAL, not an empty layout. Returning
    // defaults would silently discard whatever is in there, and the next save
    // would overwrite it — so a layout somebody could have recovered is gone
    // because a reader decided not to mention it.
    let persisted: WorktreeState = serde_json::from_str(&raw).map_err(|e| {
        internal_error(format!(
            "the saved explorer state for {} is not readable: {e}",
            state.worktree_root.display()
        ))
    })?;
    Ok(Json(persisted))
}

/// Write the layout down, replacing whatever was there.
///
/// One statement in one transaction, which is what makes this atomic. It was
/// `fs::write` to a `workbench.json` — truncate, then write — so a crash
/// between the two left an empty file and the whole layout was gone, not the
/// part being changed.
pub async fn put_workbench_state(
    State(state): State<WebState>,
    Json(body): Json<WorktreeState>,
) -> Result<Json<WorktreeState>, (StatusCode, Json<ErrorResponse>)> {
    let service = state.db_service.clone();
    let key = state.worktree_key.clone();
    let json = serde_json::to_string(&body)
        .map_err(|e| internal_error(format!("failed to serialize workbench state: {e}")))?;

    tokio::task::spawn_blocking(move || {
        let conn = service
            .get_connection()
            .map_err(|e| format!("cannot open the workshop's database: {e}"))?;
        conn.execute(UPSERT_LAYOUT, rusqlite::params![&key, &json])
        .map_err(|e| format!("cannot save the workbench layout: {e}"))
    })
    .await
    .map_err(|e| internal_error(format!("put_workbench_state task panicked: {e}")))?
    .map_err(internal_error)?;

    Ok(Json(body))
}

fn internal_error(message: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
    let msg = format!("{message:#}");
    tracing::error!("{msg}");
    (
        StatusCode::INTERNAL_SERVER_ERROR,
        Json(ErrorResponse { error: msg }),
    )
}

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