web: add 'ranger serve' subcommand with three-panel HTML layout
Add axum as a dependency. Create a 'ranger serve' subcommand that starts
an HTTP server on a configurable port (default 3000). Requires --backlog
(or RANGER_DEFAULT_BACKLOG env) to specify which backlog to display.
GET / renders a three-panel board with live data from SQLite:
- Left panel: unified backlog stream (in_progress flowing into queued)
- Middle panel (280px): icebox
- Right panel (300px): done
Header bar shows 'ranger › <backlog_name>' with active/total task counts.
Each task card displays short key, title, optional description, and
subtask progress indicator. Dark theme with color-coded state borders.
diff --git a/Cargo.lock b/Cargo.lock
index f62b2e7..1d344ea 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -112,12 +112,70 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "axum"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -665,6 +723,87 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "pin-utils",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -930,6 +1069,12 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "md-5"
version = "0.10.6"
@@ -946,6 +1091,12 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1104,6 +1255,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
[[package]]
name = "pkcs1"
version = "0.7.5"
@@ -1269,6 +1426,7 @@ name = "ranger"
version = "0.1.0"
dependencies = [
"assert_cmd",
+ "axum",
"clap",
"color-eyre",
"jiff",
@@ -1431,6 +1589,17 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1772,6 +1941,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -1895,6 +2070,34 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
[[package]]
name = "tracing"
version = "0.1.44"
diff --git a/Cargo.toml b/Cargo.toml
index 3b23f2f..fcff23b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,6 +8,7 @@ name = "ranger"
path = "src/bin/ranger/main.rs"
[dependencies]
+axum = "*"
color-eyre = "*"
tracing-subscriber = { version = "*", features = ["env-filter"] }
jiff = { version = "*", features = ["serde"] }
diff --git a/src/bin/ranger/commands/mod.rs b/src/bin/ranger/commands/mod.rs
index e0afbd2..5c55ef4 100644
--- a/src/bin/ranger/commands/mod.rs
+++ b/src/bin/ranger/commands/mod.rs
@@ -1,3 +1,4 @@
pub mod backlog;
pub mod comment;
+pub mod serve;
pub mod task;
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
new file mode 100644
index 0000000..200ca08
--- /dev/null
+++ b/src/bin/ranger/commands/serve.rs
@@ -0,0 +1,408 @@
+use axum::extract::State;
+use axum::response::Html;
+use axum::{Router, routing::get};
+use ranger::key;
+use ranger::models::Task;
+use ranger::ops;
+use ranger::ops::task::ListFilter;
+use sqlx::SqlitePool;
+use std::net::SocketAddr;
+use tokio::net::TcpListener;
+
+#[derive(Clone)]
+struct AppState {
+ pool: SqlitePool,
+ backlog_name: String,
+}
+
+pub async fn run(pool: &SqlitePool, port: u16, backlog_name: String) -> color_eyre::Result<()> {
+ let state = AppState {
+ pool: pool.clone(),
+ backlog_name,
+ };
+
+ let app = Router::new().route("/", get(index)).with_state(state);
+
+ let addr = SocketAddr::from(([0, 0, 0, 0], port));
+ eprintln!("Listening on http://{addr}");
+
+ let listener = TcpListener::bind(addr).await?;
+ axum::serve(listener, app).await?;
+
+ Ok(())
+}
+
+async fn index(State(state): State<AppState>) -> Html<String> {
+ match render_board(&state).await {
+ Ok(html) => Html(html),
+ Err(e) => Html(format!(
+ "<html><body><h1>Error</h1><pre>{e}</pre></body></html>"
+ )),
+ }
+}
+
+struct TaskView {
+ short_key: String,
+ title: String,
+ description: Option<String>,
+ has_subtasks: bool,
+ subtask_count: usize,
+ done_subtask_count: usize,
+}
+
+async fn render_board(state: &AppState) -> color_eyre::Result<String> {
+ let mut conn = state.pool.acquire().await?;
+ let backlog = ops::backlog::get_by_name(&mut conn, &state.backlog_name).await?;
+ let all_keys = ops::task::keys_for_backlog(&mut conn, backlog.id).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+
+ let mut in_progress = Vec::new();
+ let mut queued = Vec::new();
+ let mut icebox = Vec::new();
+ let mut done = Vec::new();
+
+ for s in [
+ ranger::models::State::InProgress,
+ ranger::models::State::Queued,
+ ranger::models::State::Icebox,
+ ranger::models::State::Done,
+ ] {
+ let filter = ListFilter {
+ state: Some(s.clone()),
+ ..Default::default()
+ };
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
+ let views = to_task_views(&tasks, &prefixes, &mut conn).await?;
+ match s {
+ ranger::models::State::InProgress => in_progress = views,
+ ranger::models::State::Queued => queued = views,
+ ranger::models::State::Icebox => icebox = views,
+ ranger::models::State::Done => done = views,
+ }
+ }
+
+ let total = in_progress.len() + queued.len() + icebox.len() + done.len();
+ let active = in_progress.len() + queued.len();
+
+ let backlog_panel = render_backlog_panel(&in_progress, &queued);
+ let icebox_panel = render_column_panel("icebox", &icebox);
+ let done_panel = render_column_panel("done", &done);
+
+ Ok(format!(
+ r##"<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>ranger › {backlog_name}</title>
+<style>
+ :root {{
+ --bg: #0e0e10;
+ --surface: #1a1a1e;
+ --border: #2a2a2e;
+ --text: #e0e0e0;
+ --text-dim: #888;
+ --accent: #7c6fe0;
+ --green: #4caf50;
+ --yellow: #e0b44c;
+ --blue: #5b9fe0;
+ }}
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
+ body {{
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
+ }}
+ header {{
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 24px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ }}
+ header h1 {{
+ font-size: 16px;
+ font-weight: 600;
+ }}
+ header h1 span.sep {{
+ color: var(--text-dim);
+ margin: 0 6px;
+ }}
+ header .counts {{
+ font-size: 13px;
+ color: var(--text-dim);
+ }}
+ .board {{
+ display: grid;
+ grid-template-columns: 1fr 280px 300px;
+ height: calc(100vh - 49px);
+ }}
+ .panel {{
+ border-right: 1px solid var(--border);
+ overflow-y: auto;
+ padding: 16px;
+ }}
+ .panel:last-child {{
+ border-right: none;
+ }}
+ .panel-header {{
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+ }}
+ .panel-header h2 {{
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim);
+ }}
+ .panel-header .count {{
+ font-size: 11px;
+ color: var(--text-dim);
+ background: var(--border);
+ padding: 1px 6px;
+ border-radius: 8px;
+ }}
+ .section-label {{
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--accent);
+ margin: 16px 0 8px 0;
+ }}
+ .section-label:first-child {{
+ margin-top: 0;
+ }}
+ .task {{
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 10px 12px;
+ margin-bottom: 6px;
+ }}
+ .task .task-header {{
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ }}
+ .task .key {{
+ font-family: "SF Mono", "Fira Code", monospace;
+ font-size: 11px;
+ color: var(--accent);
+ flex-shrink: 0;
+ }}
+ .task .title {{
+ font-size: 14px;
+ font-weight: 500;
+ }}
+ .task .desc {{
+ font-size: 12px;
+ color: var(--text-dim);
+ margin-top: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }}
+ .task .subtask-indicator {{
+ font-size: 11px;
+ color: var(--text-dim);
+ margin-top: 4px;
+ }}
+ .state-in_progress .task {{ border-left: 3px solid var(--yellow); }}
+ .state-queued .task {{ border-left: 3px solid var(--blue); }}
+ .state-done .task {{
+ border-left: 3px solid var(--green);
+ opacity: 0.7;
+ }}
+ .empty {{
+ font-size: 13px;
+ color: var(--text-dim);
+ font-style: italic;
+ padding: 20px 0;
+ text-align: center;
+ }}
+</style>
+</head>
+<body>
+<header>
+ <h1>ranger<span class="sep">›</span>{backlog_name}</h1>
+ <div class="counts">{active} active · {total} total</div>
+</header>
+<div class="board">
+ {backlog_panel}
+ {icebox_panel}
+ {done_panel}
+</div>
+</body>
+</html>"##,
+ backlog_name = html_escape(&state.backlog_name),
+ ))
+}
+
+fn render_backlog_panel(in_progress: &[TaskView], queued: &[TaskView]) -> String {
+ let count = in_progress.len() + queued.len();
+ let mut html = String::new();
+ html.push_str(&format!(
+ r#"<div class="panel">
+ <div class="panel-header">
+ <h2>Backlog</h2>
+ <span class="count">{count}</span>
+ </div>"#
+ ));
+
+ if in_progress.is_empty() && queued.is_empty() {
+ html.push_str(r#"<div class="empty">No active tasks</div>"#);
+ } else {
+ if !in_progress.is_empty() {
+ html.push_str(r#"<div class="section-label">In Progress</div>"#);
+ html.push_str(r#"<div class="state-in_progress">"#);
+ for task in in_progress {
+ html.push_str(&render_task(task));
+ }
+ html.push_str("</div>");
+ }
+
+ if !queued.is_empty() {
+ html.push_str(r#"<div class="section-label">Queued</div>"#);
+ html.push_str(r#"<div class="state-queued">"#);
+ for task in queued {
+ html.push_str(&render_task(task));
+ }
+ html.push_str("</div>");
+ }
+ }
+
+ html.push_str("</div>");
+ html
+}
+
+fn render_column_panel(name: &str, tasks: &[TaskView]) -> String {
+ let label = match name {
+ "icebox" => "Icebox",
+ "done" => "Done",
+ _ => name,
+ };
+ let state_class = match name {
+ "done" => "state-done",
+ _ => "",
+ };
+ let count = tasks.len();
+ let mut html = String::new();
+ html.push_str(&format!(
+ r#"<div class="panel">
+ <div class="panel-header">
+ <h2>{label}</h2>
+ <span class="count">{count}</span>
+ </div>"#
+ ));
+
+ if tasks.is_empty() {
+ html.push_str(&format!(
+ r#"<div class="empty">No {lower} tasks</div>"#,
+ lower = label.to_lowercase()
+ ));
+ } else {
+ if !state_class.is_empty() {
+ html.push_str(&format!(r#"<div class="{state_class}">"#));
+ }
+ for task in tasks {
+ html.push_str(&render_task(task));
+ }
+ if !state_class.is_empty() {
+ html.push_str("</div>");
+ }
+ }
+
+ html.push_str("</div>");
+ html
+}
+
+fn render_task(task: &TaskView) -> String {
+ let mut html = String::new();
+ html.push_str(r#"<div class="task">"#);
+ html.push_str(r#"<div class="task-header">"#);
+ html.push_str(&format!(
+ r#"<span class="key">{}</span>"#,
+ html_escape(&task.short_key)
+ ));
+ html.push_str(&format!(
+ r#"<span class="title">{}</span>"#,
+ html_escape(&task.title)
+ ));
+ html.push_str("</div>");
+ if let Some(desc) = &task.description {
+ html.push_str(&format!(
+ r#"<div class="desc">{}</div>"#,
+ html_escape(desc)
+ ));
+ }
+ if task.has_subtasks {
+ html.push_str(&format!(
+ r#"<div class="subtask-indicator">◆ {}/{} subtasks</div>"#,
+ task.done_subtask_count, task.subtask_count
+ ));
+ }
+ html.push_str("</div>");
+ html
+}
+
+async fn to_task_views(
+ tasks: &[Task],
+ prefixes: &std::collections::HashMap<String, usize>,
+ conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
+) -> color_eyre::Result<Vec<TaskView>> {
+ let mut views = Vec::with_capacity(tasks.len());
+ for task in tasks {
+ let prefix_len = prefixes.get(&task.key).copied().unwrap_or(8);
+ let short_key = task.key[..8.min(task.key.len())].to_string();
+ let short_key = format!(
+ "{}{}",
+ &short_key[..prefix_len.min(short_key.len())],
+ if prefix_len < short_key.len() {
+ &short_key[prefix_len..]
+ } else {
+ ""
+ }
+ );
+
+ // Check for subtasks
+ let subtasks: Vec<Task> = sqlx::query_as(
+ "SELECT id, key, backlog_id, parent_id, title, description, state, position, archived, created_at, updated_at \
+ FROM tasks WHERE parent_id = ? AND archived = 0 ORDER BY position",
+ )
+ .bind(task.id)
+ .fetch_all(&mut **conn)
+ .await?;
+
+ let has_subtasks = !subtasks.is_empty();
+ let subtask_count = subtasks.len();
+ let done_subtask_count = subtasks
+ .iter()
+ .filter(|t| t.state == ranger::models::State::Done)
+ .count();
+
+ views.push(TaskView {
+ short_key,
+ title: task.title.clone(),
+ description: task.description.clone(),
+ has_subtasks,
+ subtask_count,
+ done_subtask_count,
+ });
+ }
+ Ok(views)
+}
+
+fn html_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index 395abe1..11f4036 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -40,6 +40,15 @@ enum Commands {
#[command(subcommand)]
command: commands::comment::CommentCommands,
},
+ /// Start the web server
+ Serve {
+ /// Port to listen on
+ #[arg(long, default_value_t = 3000)]
+ port: u16,
+ /// Backlog to display
+ #[arg(long, env = "RANGER_DEFAULT_BACKLOG")]
+ backlog: String,
+ },
}
fn resolve_db_path(cli_path: Option<PathBuf>) -> PathBuf {
@@ -73,6 +82,9 @@ async fn main() -> color_eyre::Result<()> {
Some(Commands::Comment { command }) => {
commands::comment::run(&pool, command, cli.json).await?;
}
+ Some(Commands::Serve { port, backlog }) => {
+ commands::serve::run(&pool, port, backlog).await?;
+ }
None => {
// No subcommand: show the default backlog
let backlog_name = std::env::var("RANGER_DEFAULT_BACKLOG").ok();