wsb-daemon 0.248.6265

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;

/// The full persisted workbench layout state.
/// The frontend owns the schema for `input` on each tab — we store it
/// as opaque JSON so the backend never needs to understand editor input types.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedWorkbenchState {
    pub groups: Vec<PersistedEditorGroup>,
    pub active_group_id: String,
    #[serde(default = "default_group_sizes")]
    pub editor_group_sizes: Vec<f64>,
    pub sidebar_open: bool,
    #[serde(default = "default_sidebar_width")]
    pub sidebar_width: u32,
    pub sidebar_active_container_id: String,
    pub activity_bar_order: Vec<String>,
    pub bottom_panel_open: bool,
    pub bottom_panel_height: u32,
    pub bottom_panel_active_id: String,
    pub bottom_panel_order: Vec<String>,
    pub expanded_paths: Vec<String>,
    #[serde(default)]
    pub sort_newest_first: bool,
    #[serde(default = "default_message_layout")]
    pub message_layout: String,
    #[serde(default = "default_transcript_mode")]
    pub transcript_mode: String,
}

fn default_group_sizes() -> Vec<f64> {
    vec![1.0]
}

fn default_sidebar_width() -> u32 {
    320
}

fn default_message_layout() -> String {
    "default".to_string()
}

fn default_transcript_mode() -> String {
    "normal".to_string()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedEditorGroup {
    pub id: String,
    pub tabs: Vec<PersistedEditorTab>,
    pub active_tab_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedEditorTab {
    pub id: String,
    pub label: String,
    pub description: String,
    pub input: serde_json::Value,
    pub pinned: bool,
}

/// Read one workspace's saved layout.
///
/// 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 workspace_root = ?1";

/// Write one workspace's layout, replacing whatever was there.
///
/// An upsert rather than an insert: `workspace_root` is the primary 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 (workspace_root, state_json, updated_at)
     VALUES (?1, ?2, datetime('now'))
     ON CONFLICT(workspace_root) DO UPDATE SET
       state_json = excluded.state_json,
       updated_at = excluded.updated_at";

/// The layout this workspace left open, or `None` if it has never saved one.
///
/// Keyed by the workspace root: one daemon serves several, and the tabs open
/// in one are about its files.
pub async fn get_workbench_state(
    State(state): State<WebState>,
) -> Result<Json<PersistedWorkbenchState>, (StatusCode, Json<ErrorResponse>)> {
    let service = state.db_service.clone();
    let key = state.workspace_root.display().to_string();

    let held: Option<String> = tokio::task::spawn_blocking(move || {
        let conn = service
            .get_connection()
            .map_err(|e| format!("cannot open the workspace 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 workspace starts this way, and
        // the frontend opens its defaults.
        return Err((
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: "no saved workbench 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: PersistedWorkbenchState = serde_json::from_str(&raw).map_err(|e| {
        internal_error(format!(
            "the saved workbench layout for {} is not readable: {e}",
            state.workspace_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 `.wsb/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<PersistedWorkbenchState>,
) -> Result<Json<PersistedWorkbenchState>, (StatusCode, Json<ErrorResponse>)> {
    let service = state.db_service.clone();
    let key = state.workspace_root.display().to_string();
    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 workspace 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;