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;
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()
.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))
.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),
)
.route("/search", get(handlers::search::global_search))
.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),
)
.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))
.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),
)
.route("/trash", get(handlers::trash::list_trash))
.route("/trash", delete(handlers::trash::empty_trash))
.route("/settings", get(handlers::settings::get_settings))
.route("/settings", put(handlers::settings::update_settings))
.route("/workspace/tree", get(handlers::workspace::list_tree))
.route("/workspace/file", get(handlers::workspace::read_file))
.route("/version", get(handlers::version::get_version))
.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),
)
.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))
.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)
}
async fn spa_handler(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
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();
}
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(),
}
}