wsb 0.93.391653

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
pub mod db;
pub mod github;
pub mod handlers;
pub mod parser;
pub mod sse;
pub mod state;
pub mod sync;

use anyhow::Result;
use axum::{
    body::{boxed, Full},
    http::{header, StatusCode, Uri},
    response::{IntoResponse, Response},
    routing::{delete, get, patch, post, put},
    Router,
};
use rust_embed::RustEmbed;
use std::net::{SocketAddr, TcpListener};
use tower_http::cors::CorsLayer;

use state::WebState;

#[derive(RustEmbed)]
#[folder = "frontend/dist"]
struct FrontendAssets;

/// Start the web server on the given port.
/// This replaces the old MCP server stub.
pub async fn start_server(port: u16, debug: bool) -> Result<()> {
    if debug {
        eprintln!("[wsb] starting web server on port {}", port);
    }

    let state = WebState::new(debug, port).await?;
    let app = create_router(state.clone());
    let addr = SocketAddr::from(([127, 0, 0, 1], port));
    eprintln!("[wsb] listening on http://{}", addr);

    let background_state = state.clone();
    tokio::spawn(async move {
        let engine = background_state.sync_engine.clone();
        let mut eng = engine.lock().await;
        if let Err(e) = eng.sync_incremental().await {
            eprintln!("[wsb] initial sync failed: {}", e);
        }
    });

    let watcher_state = state;
    tokio::spawn(async move {
        if let Err(e) = sync::start_watcher(watcher_state).await {
            eprintln!("[wsb] file watcher failed: {}", e);
        }
    });

    let listener = TcpListener::bind(addr)?;
    listener.set_nonblocking(true)?;

    axum::Server::from_tcp(listener)?
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

fn create_router(state: WebState) -> Router {
    let api = Router::new()
        // Sessions
        .route("/sessions", get(handlers::sessions::list_sessions))
        .route("/sessions/:id", get(handlers::sessions::get_session))
        .route(
            "/sessions/:id/messages",
            get(handlers::sessions::get_messages),
        )
        .route(
            "/sessions/:id/children",
            get(handlers::sessions::get_children),
        )
        .route(
            "/sessions/:id/minimap",
            get(handlers::sessions::get_minimap),
        )
        .route(
            "/sessions/:id/search",
            get(handlers::sessions::search_session),
        )
        .route(
            "/sessions/:id/watch",
            get(handlers::sessions::watch_session),
        )
        .route(
            "/sessions/:id/rename",
            patch(handlers::sessions::rename_session),
        )
        .route(
            "/sessions/:id",
            delete(handlers::sessions::delete_session),
        )
        .route(
            "/sessions/:id/restore",
            post(handlers::sessions::restore_session),
        )
        .route(
            "/sessions/:id/permanent",
            delete(handlers::sessions::permanent_delete),
        )
        .route("/projects", get(handlers::sessions::list_projects))
        .route("/machines", get(handlers::sessions::list_machines))
        .route("/agents", get(handlers::sessions::list_agents))
        .route("/stats", get(handlers::sessions::get_stats))
        // Analytics
        .route(
            "/analytics/summary",
            get(handlers::analytics::summary),
        )
        .route(
            "/analytics/activity",
            get(handlers::analytics::activity),
        )
        .route(
            "/analytics/heatmap",
            get(handlers::analytics::heatmap),
        )
        .route(
            "/analytics/projects",
            get(handlers::analytics::projects),
        )
        .route(
            "/analytics/hour-of-week",
            get(handlers::analytics::hour_of_week),
        )
        .route(
            "/analytics/top-sessions",
            get(handlers::analytics::top_sessions),
        )
        // Search
        .route("/search", get(handlers::search::global_search))
        // Sync
        .route("/sync", post(handlers::sync_handler::trigger_sync))
        .route(
            "/resync",
            post(handlers::sync_handler::trigger_resync),
        )
        .route(
            "/sync/status",
            get(handlers::sync_handler::sync_status),
        )
        // Starred
        .route("/starred", get(handlers::starred::list_starred))
        .route(
            "/sessions/:id/star",
            put(handlers::starred::star_session),
        )
        .route(
            "/sessions/:id/star",
            delete(handlers::starred::unstar_session),
        )
        .route("/starred/bulk", post(handlers::starred::bulk_star))
        // Pins
        .route("/pins", get(handlers::pins::list_all_pins))
        .route(
            "/sessions/:id/pins",
            get(handlers::pins::list_session_pins),
        )
        .route(
            "/sessions/:id/messages/:message_id/pin",
            post(handlers::pins::pin_message),
        )
        .route(
            "/sessions/:id/messages/:message_id/pin",
            delete(handlers::pins::unpin_message),
        )
        // Trash
        .route("/trash", get(handlers::trash::list_trash))
        .route("/trash", delete(handlers::trash::empty_trash))
        // Settings
        .route("/settings", get(handlers::settings::get_settings))
        .route("/settings", put(handlers::settings::update_settings))
        // Workspace
        .route("/workspace/tree", get(handlers::workspace::list_tree))
        .route("/workspace/file", get(handlers::workspace::read_file))
        // Version
        .route("/version", get(handlers::version::get_version))
        // Issues
        .route("/issues", get(handlers::issues::list_issues))
        .route(
            "/issues/:owner/:repo",
            get(handlers::issues::list_repo_issues),
        )
        .route(
            "/issues/:owner/:repo",
            post(handlers::issues::create_issue),
        )
        .route(
            "/issues/:owner/:repo/:number",
            patch(handlers::issues::update_issue),
        )
        .route(
            "/issues/:owner/:repo/:number/comments",
            get(handlers::issues::list_comments),
        )
        .route(
            "/issues/:owner/:repo/:number/comments",
            post(handlers::issues::add_comment),
        )
        // Gists
        .route("/gists", get(handlers::gists::list_gists))
        .route("/gists", post(handlers::gists::create_gist))
        .route("/gists/:id", get(handlers::gists::get_gist))
        .route("/gists/:id", patch(handlers::gists::update_gist))
        // Fleet
        .route("/fleet/status", get(handlers::fleet::fleet_status))
        .route("/fleet/repos", get(handlers::fleet::fleet_repos));

    Router::new()
        .nest("/api/v1", api)
        .fallback(spa_handler)
        .layer(CorsLayer::permissive())
        .with_state(state)
}

/// Serve embedded frontend assets with SPA fallback to index.html
async fn spa_handler(uri: Uri) -> impl IntoResponse {
    let path = uri.path().trim_start_matches('/');

    // Try exact file match first
    if let Some(content) = FrontendAssets::get(path) {
        let mime = mime_guess::from_path(path).first_or_octet_stream();
        return Response::builder()
            .header(header::CONTENT_TYPE, mime.as_ref())
            .body(boxed(Full::from(content.data)))
            .unwrap();
    }

    // SPA fallback: serve index.html for all non-file routes
    match FrontendAssets::get("index.html") {
        Some(content) => Response::builder()
            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
            .body(boxed(Full::from(content.data)))
            .unwrap(),
        None => Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(boxed(Full::from("frontend not found")))
            .unwrap(),
    }
}