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.
change xwxxkpnnkvykpstrmtorwkopqvpturur
commit f57e7ed09b46de6b3bb80ee78b0e396ffa163b93
author Alpha Chen <alpha@kejadlen.dev>
date
parent zltxwlzl
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('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+}
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();