You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [e8c85249] Fix GitHub resolver and vote compare flow (#149) * Fix GitHub resolver and vote compare flow Co-authored-by: tommy * Fix vote compare next pair helper Co-authored-by: tommy * Extend GitHub resolver and vote pair updates Co-authored-by: tommy * Fix resolver browser refresh coverage Co-authored-by: tommy * Stabilize GitHub resolver browser test Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side A — unified diff (full patch): diff --git a/agents.md b/agents.md index c66f33789441eea193b4354fce4c03b7fffdd639..7508234d9b04223d0e64cfe69fedbebd06a256b5 100644 --- a/agents.md +++ b/agents.md @@ -36,12 +36,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below). +- **Do not add one-off POST routes** for browser mutations. New browser actions belong in **`HtmlUiAction`** behind **`POST /ui`**; new programmatic verbs belong in **`RpcCommand`** behind **`POST /api/v0/rpc`**. Ordinary shareable pages remain normal **`GET`** routes. + - **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`
`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: "/ui"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint. -- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card), **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. +- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
      `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. + +- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item. +- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection. + **Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate. --- diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index c9c4082f251838b5ac46de7ce48392c136883d55..88bb3335e4873643530351266d213f258da5893e 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -8,7 +8,10 @@ use axum::{ use axum_extra::extract::cookie::CookieJar; use base64::Engine; use serde::Deserialize; -use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse}; +use slug_types::{ + PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, + WhoamiResponse, +}; use std::{collections::HashMap, sync::Arc}; use tokio::sync::{oneshot, RwLock}; @@ -17,7 +20,8 @@ use crate::{ events::{Event, TokenIssued}, html::{ auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, - choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder, + choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, + JsBuilder, }, identity::{parse_agent, parse_username}, reducer::ReducerState, @@ -36,12 +40,26 @@ pub const SLUG_SESSION_COOKIE: &str = "slug_session"; /// `Set-Cookie` header value (full attribute string). pub fn session_cookie_header_value(bearer: &str) -> HeaderValue { - let s = format!( - "{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000" - ); + let s = + format!("{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"); HeaderValue::from_str(&s).expect("session cookie value must be ASCII") } +fn safe_local_redirect(raw: Option<&str>) -> Option { + let s = raw?.trim(); + if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 { + Some(s.to_string()) + } else { + None + } +} + +fn redirect_query(next: Option<&str>) -> String { + safe_local_redirect(next) + .map(|n| format!("&next={}", urlencoding::encode(&n))) + .unwrap_or_default() +} + fn js_form_error_fragment(session: &str, error: &str) -> Response { JsBuilder::new() .id("choose-username-form") @@ -49,11 +67,11 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response { .into_response() } -fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response { +fn js_signed_in_fragment(bearer: &str, jar: &CookieJar, redirect_to: &str) -> Response { let mut response = JsBuilder::new() .id("choose-username-form") .morph_inner(auth_signed_in_fragment()) - .redirect("/auth/complete") + .redirect(redirect_to) .into_response(); let headers = response.headers_mut(); headers.append(header::SET_COOKIE, session_cookie_header_value(bearer)); @@ -64,7 +82,11 @@ fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response { } /// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie. -pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option { +pub fn optional_principal( + headers: &HeaderMap, + jar: &CookieJar, + reduced: &ReducerState, +) -> Option { if let Ok(u) = verify_bearer_principal(headers, reduced) { return Some(u); } @@ -80,7 +102,11 @@ pub struct WebSession { } /// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar). -pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option { +pub fn resolve_web_session( + headers: &HeaderMap, + jar: &CookieJar, + reduced: &ReducerState, +) -> Option { let username = optional_principal(headers, jar, reduced)?; let bearer = headers .get(header::AUTHORIZATION) @@ -90,7 +116,12 @@ pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduc Some(WebSession { username, bearer }) } -fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response { +fn redirect_with_session_cookie( + public_url: &str, + path_and_query: &str, + bearer: &str, + jar: &CookieJar, +) -> Response { let mut res = Response::builder() .status(StatusCode::TEMPORARY_REDIRECT) .header(header::LOCATION, format!("{public_url}{path_and_query}")) @@ -112,21 +143,32 @@ fn pending_sessions(state: &AppState) -> Arc Option { let payload_b64 = jwt.split('.').nth(1)?; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64).ok()?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?; v.get("sub")?.as_str().map(|s| s.to_string()) } pub(crate) fn parse_bearer(headers: &HeaderMap) -> Result { let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else { - return Err((StatusCode::UNAUTHORIZED, "missing Authorization header".to_string())); + return Err(( + StatusCode::UNAUTHORIZED, + "missing Authorization header".to_string(), + )); }; let Ok(s) = value.to_str() else { - return Err((StatusCode::UNAUTHORIZED, "invalid Authorization header".to_string())); + return Err(( + StatusCode::UNAUTHORIZED, + "invalid Authorization header".to_string(), + )); }; let s = s.trim(); let Some(rest) = s.strip_prefix("Bearer ") else { - return Err((StatusCode::UNAUTHORIZED, "Authorization must be Bearer".to_string())); + return Err(( + StatusCode::UNAUTHORIZED, + "Authorization must be Bearer".to_string(), + )); }; Ok(rest.trim().to_string()) } @@ -140,7 +182,10 @@ pub fn verify_bearer_principal( verify_token(reduced, &bearer) } -pub(crate) fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result { +pub(crate) fn verify_token( + reduced: &crate::reducer::ReducerState, + bearer: &str, +) -> Result { // slug__ let Some(rest) = bearer.strip_prefix("slug_") else { return Err((StatusCode::UNAUTHORIZED, "invalid token format".to_string())); @@ -199,9 +244,25 @@ pub(crate) fn issue_token_for_user(stored_username: &str) -> (String, TokenIssue #[derive(Debug, Deserialize)] pub struct AuthLoginQuery { pub session: String, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, } -pub async fn get_join_invite(Path(token): Path, State(state): State) -> impl IntoResponse { +#[derive(Debug, Deserialize)] +pub struct JoinInviteQuery { + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, +} + +pub async fn get_join_invite( + Path(token): Path, + Query(q): Query, + State(state): State, +) -> impl IntoResponse { let token = token.trim().to_string(); if token.is_empty() { return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response(); @@ -219,37 +280,57 @@ pub async fn get_join_invite(Path(token): Path, State(state): State, State(state): State) -> impl IntoResponse { +pub async fn get_auth_login( + Query(q): Query, + State(state): State, +) -> impl IntoResponse { // Redirect to Google auth endpoint. let sessions = pending_sessions(&state); - let sessions_read = sessions.read().await; - let Some(_s) = sessions_read.get(&q.session) else { - return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); - }; - drop(sessions_read); + { + let mut sessions_write = sessions.write().await; + let Some(s) = sessions_write.get_mut(&q.session) else { + return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); + }; + if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) { + s.redirect_next = Some(next); + } + } let auth_url_base = std::env::var("SLUG_GOOGLE_AUTH_URL") .unwrap_or_else(|_| "https://accounts.google.com/o/oauth2/v2/auth".to_string()); let client_id = std::env::var("SLUG_GOOGLE_CLIENT_ID").unwrap_or_else(|_| "dev".to_string()); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); let redirect_uri = format!("{public_url}/auth/callback"); let auth_url = format!( "{auth_url_base}?client_id={}&redirect_uri={}&response_type=code&scope=openid%20email&state={}", @@ -282,8 +363,10 @@ pub async fn get_auth_callback( let token_url = std::env::var("SLUG_GOOGLE_TOKEN_URL") .unwrap_or_else(|_| "https://oauth2.googleapis.com/token".to_string()); let client_id = std::env::var("SLUG_GOOGLE_CLIENT_ID").unwrap_or_else(|_| "dev".to_string()); - let client_secret = std::env::var("SLUG_GOOGLE_CLIENT_SECRET").unwrap_or_else(|_| "dev".to_string()); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let client_secret = + std::env::var("SLUG_GOOGLE_CLIENT_SECRET").unwrap_or_else(|_| "dev".to_string()); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); let redirect_uri = format!("{public_url}/auth/callback"); // Exchange code for id_token + access_token. @@ -306,16 +389,37 @@ pub async fn get_auth_callback( { Ok(resp) => match resp.json().await { Ok(v) => v, - Err(err) => return api_error(StatusCode::BAD_GATEWAY, "oauth token exchange failed", Some(format!("{err}"))).into_response(), + Err(err) => { + return api_error( + StatusCode::BAD_GATEWAY, + "oauth token exchange failed", + Some(format!("{err}")), + ) + .into_response() + } }, - Err(err) => return api_error(StatusCode::BAD_GATEWAY, "oauth token exchange failed", Some(format!("{err}"))).into_response(), + Err(err) => { + return api_error( + StatusCode::BAD_GATEWAY, + "oauth token exchange failed", + Some(format!("{err}")), + ) + .into_response() + } }; // Extract sub from the id_token JWT payload (base64-decode middle segment). // The token arrived directly from Google over TLS — no need for an extra userinfo roundtrip. let sub = match extract_jwt_sub(&tr.id_token) { Some(s) => s, - None => return api_error(StatusCode::BAD_GATEWAY, "oauth: could not extract sub from id_token", None).into_response(), + None => { + return api_error( + StatusCode::BAD_GATEWAY, + "oauth: could not extract sub from id_token", + None, + ) + .into_response() + } }; // If user exists, issue token and complete session. Otherwise redirect to choose-username. @@ -327,7 +431,9 @@ pub async fn get_auth_callback( { let mut sessions_write = sessions.write().await; - let s = sessions_write.get_mut(&q.state).expect("session checked above"); + let s = sessions_write + .get_mut(&q.state) + .expect("session checked above"); s.provider = Some("google".to_string()); s.provider_id = Some(sub.clone()); if let Some(username) = existing { @@ -345,31 +451,61 @@ pub async fn get_auth_callback( .await .is_err() { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer unavailable", None).into_response(); + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "writer unavailable", + None, + ) + .into_response(); } match rx.await { Err(_) => { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None).into_response(); + return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None) + .into_response(); } Ok(Err(err)) => { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to persist token", Some(err)) - .into_response(); + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to persist token", + Some(err), + ) + .into_response(); } Ok(Ok(())) => {} } + let redirect_to = + safe_local_redirect(s.redirect_next.as_deref()).unwrap_or_else(|| "/".to_string()); let cookie_bearer = bearer.clone(); s.complete = Some((username, bearer)); - return redirect_with_session_cookie(&public_url, "/", &cookie_bearer, &jar).into_response(); + return redirect_with_session_cookie(&public_url, &redirect_to, &cookie_bearer, &jar) + .into_response(); } } - Redirect::temporary(&format!("{public_url}/auth/choose-username?session={}", q.state)).into_response() + let next_q = { + let sessions_read = sessions.read().await; + sessions_read + .get(&q.state) + .and_then(|s| s.redirect_next.as_deref()) + .map(|n| redirect_query(Some(n))) + .unwrap_or_default() + }; + Redirect::temporary(&format!( + "{public_url}/auth/choose-username?session={}{}", + urlencoding::encode(&q.state), + next_q + )) + .into_response() } #[derive(Debug, Deserialize)] pub struct ChooseUsernameQuery { pub session: String, pub error: Option, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, } pub async fn get_choose_username( @@ -379,13 +515,18 @@ pub async fn get_choose_username( uri: Uri, ) -> impl IntoResponse { let sessions = pending_sessions(&state); - let sessions_read = sessions.read().await; - if !sessions_read.contains_key(&q.session) { - return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); + { + let mut sessions_write = sessions.write().await; + let Some(s) = sessions_write.get_mut(&q.session) else { + return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); + }; + if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) { + s.redirect_next = Some(next); + } } - drop(sessions_read); let next = theme_next_from_uri(&uri); - choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response() + choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next) + .into_response() } #[derive(Debug, Deserialize)] @@ -401,7 +542,10 @@ pub async fn post_choose_username( ) -> impl IntoResponse { let canon_user = match parse_username(&form.username) { Ok(u) => u, - Err(msg) => return js_form_error_fragment(&form.session, &format!("invalid username — {msg}")).into_response(), + Err(msg) => { + return js_form_error_fragment(&form.session, &format!("invalid username — {msg}")) + .into_response() + } }; let sessions = pending_sessions(&state); @@ -420,12 +564,15 @@ pub async fn post_choose_username( }; if let Err(msg) = parse_agent(&agent) { - return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}")).into_response(); + return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}")) + .into_response(); } let redeem_invite = { let sessions_read = sessions.read().await; - sessions_read.get(&form.session).and_then(|s| s.redeem_invite.clone()) + sessions_read + .get(&form.session) + .and_then(|s| s.redeem_invite.clone()) }; let (tx, rx) = oneshot::channel(); @@ -441,12 +588,18 @@ pub async fn post_choose_username( .await .is_err() { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer unavailable", None).into_response(); + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "writer unavailable", + None, + ) + .into_response(); } let bearer = match rx.await { Err(_) => { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None).into_response(); + return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None) + .into_response(); } Ok(Err(msg)) => { return js_form_error_fragment(&form.session, &msg).into_response(); @@ -454,31 +607,59 @@ pub async fn post_choose_username( Ok(Ok(b)) => b, }; - { + let redirect_to = { let mut sessions_write = sessions.write().await; - let s = sessions_write.get_mut(&form.session).expect("session checked above"); + let s = sessions_write + .get_mut(&form.session) + .expect("session checked above"); s.complete = Some((canon_user.clone(), bearer.clone())); - } + safe_local_redirect(s.redirect_next.as_deref()) + .unwrap_or_else(|| "/auth/complete".to_string()) + }; - js_signed_in_fragment(&bearer, &jar).into_response() + js_signed_in_fragment(&bearer, &jar, &redirect_to).into_response() } /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success. -pub async fn get_web_login(State(state): State) -> impl IntoResponse { +#[derive(Debug, Deserialize)] +pub struct WebLoginQuery { + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, +} + +pub async fn get_web_login( + Query(q): Query, + State(state): State, +) -> impl IntoResponse { let session = format!("p_{}", uuid::Uuid::new_v4().simple()); + let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) + .or_else(|| Some("/".to_string())); let s = PendingSession { agent: WEB_BROWSER_AGENT.to_string(), created_ts: now_ms(), provider: None, provider_id: None, redeem_invite: None, + redirect_next: redirect_next.clone(), complete: None, }; - state.pending_sessions.write().await.insert(session.clone(), s); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + state + .pending_sessions + .write() + .await + .insert(session.clone(), s); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let next_q = redirect_next + .as_deref() + .map(|n| redirect_query(Some(n))) + .unwrap_or_default(); Redirect::temporary(&format!( - "{public_url}/auth/login?session={}", - urlencoding::encode(&session) + "{public_url}/auth/login?session={}{}", + urlencoding::encode(&session), + next_q )) .into_response() } @@ -504,12 +685,17 @@ pub async fn post_pending_session( let agent_naked = match parse_agent(&req.agent) { Ok(a) => a, Err(msg) => { - return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response(); + return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)) + .into_response(); } }; let session = format!("p_{}", uuid::Uuid::new_v4().simple()); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); - let login_url = format!("{public_url}/auth/login?session={}", urlencoding::encode(&session)); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let login_url = format!( + "{public_url}/auth/login?session={}", + urlencoding::encode(&session) + ); let poll_url = format!("/api/v0/pending-session/{}", session); let s = PendingSession { agent: agent_naked, @@ -517,6 +703,7 @@ pub async fn post_pending_session( provider: None, provider_id: None, redeem_invite: None, + redirect_next: None, complete: None, }; let sessions = pending_sessions(&state); @@ -567,11 +754,14 @@ pub async fn get_whoami(State(state): State, headers: HeaderMap) -> im Ok(u) => u, Err((st, msg)) => return api_error(st, msg, None).into_response(), }; - let agents_bound = reduced.agent_bindings.values().filter(|u| *u == &username).count(); + let agents_bound = reduced + .agent_bindings + .values() + .filter(|u| *u == &username) + .count(); Json(WhoamiResponse { user: username, agents_bound, }) .into_response() } - diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 336d54bfad5037e9d87437400747260d160bee2a..146792b9ec33d539ce8c6b106768162805dbb3f9 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -20,6 +20,7 @@ use crate::{ path_types::ItemId, ranking::{connected_components_from_voted_pairs, ranked_items_subset}, reducer::{scope_from_room_wire, ReducerState, ScopeId}, + scope_rank::suggest_next_pair_in_pool, state::{AppState, InviteState}, write_cmd::WriteCmd, }; @@ -759,7 +760,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re } } } - pick.or_else(|| pick_random_distinct_item_pair(&pool)) + pick.or_else(|| suggest_next_pair_in_pool(group, &pool, None)) + .or_else(|| pick_random_distinct_item_pair(&pool)) } }; let Some((left, right)) = selected else { diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index cd501ba6d4d656eabad03afed4583efdf885695d..4b0214d18b173cd506d09176104f461dc4c4f208 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -21,11 +21,11 @@ use crate::{ external_resolver::resolve_github_children, html::vote_compare_post_success_js, html::{ - fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form, - room_members_section_markup, thread_feed_html, thread_feed_html_for_room, - thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, - thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room, - HtmlUiAction, JsBuilder, ThreadNav, + external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup, + parse_html_ui_from_form, room_members_section_markup, thread_feed_html, + thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post, + thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, + user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav, }, reducer::{scope_from_room_wire, ScopeId}, state::AppState, @@ -68,6 +68,13 @@ fn sanitize_garden_pin_next(next: &str) -> String { } } +fn login_redirect_for(next: &str) -> String { + format!( + "/login?next={}", + urlencoding::encode(&sanitize_garden_pin_next(next)) + ) +} + fn redirect_with_pin_cookie(cookie_header_value: &str, location: &str) -> Response { let loc = HeaderValue::try_from(location).unwrap_or_else(|_| HeaderValue::from_static("/")); let hv = HeaderValue::try_from(cookie_header_value).expect("set-cookie header"); @@ -194,7 +201,7 @@ async fn dispatch_ui_action( .into_response(); } let Some(session) = session else { - return js_redirect("/login").into_response(); + return js_redirect(&login_redirect_for(&next)).into_response(); }; let err_tgt = Some("vote-compare-errors".to_string()); let room = room.trim().to_string(); @@ -363,7 +370,7 @@ async fn dispatch_ui_action( .into_response(); } let Some(session) = session else { - return js_redirect("/login").into_response(); + return js_redirect(&login_redirect_for(&next)).into_response(); }; let room = room_wire.trim(); if room.is_empty() { @@ -390,8 +397,19 @@ async fn dispatch_ui_action( item.normalized_storage() }; match resolve_github_children(state, room, &target).await { - Ok(_) => js_redirect(&sanitize_garden_pin_next(&next)).into_response(), - Err(msg) => ui_js_warn(&msg).into_response(), + Ok(n) => JsBuilder::new() + .morph_inner_selector( + "#external-resolver-status", + external_resolver_status_markup(Ok(n), &sanitize_garden_pin_next(&next)), + ) + .redirect(&sanitize_garden_pin_next(&next)) + .into_response(), + Err(msg) => JsBuilder::new() + .morph_inner_selector( + "#external-resolver-status", + external_resolver_status_markup(Err(msg.as_str()), &next), + ) + .into_response(), } } HtmlUiAction::RedactPost { post_id } => { diff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs index 04bc3de32b6df1aefe197d942db67de2cbdbde4f..a5812250fed7613950b5417f396f886a55fafccf 100644 --- a/server/src/external_resolver.rs +++ b/server/src/external_resolver.rs @@ -6,6 +6,7 @@ use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd}; const GITHUB_SYSTEM_PRINCIPAL: &str = "system:github-resolver"; const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000; +const GITHUB_MAX_PAGES: usize = 3; fn now_ms() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; @@ -69,6 +70,10 @@ impl GitHubResolver { [owner, repo] => Ok(github_repo_sections(owner, repo)), [owner, repo, section] if section == "issues" => self.list_issues(owner, repo).await, [owner, repo, section] if section == "pulls" => self.list_pulls(owner, repo).await, + [owner, repo, section] if section == "commits" => self.list_commits(owner, repo).await, + [owner, repo, section] if section == "releases" => { + self.list_releases(owner, repo).await + } _ => Ok(vec![]), } } @@ -95,17 +100,31 @@ impl GitHubResolver { .map_err(|e| format!("GitHub response JSON failed: {e}")) } + async fn get_json_array_pages(&self, path: &str) -> Result, String> { + let sep = if path.contains('?') { '&' } else { '?' }; + let mut out = Vec::new(); + for page in 1..=GITHUB_MAX_PAGES { + let value = self.get_json(&format!("{path}{sep}page={page}")).await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub paged response was not an array".to_string())?; + let n = arr.len(); + out.extend(arr.iter().cloned()); + if n < 100 { + break; + } + } + Ok(out) + } + async fn list_repos(&self, owner: &str) -> Result, String> { - let value = self - .get_json(&format!( + let arr = self + .get_json_array_pages(&format!( "/users/{owner}/repos?per_page=100&sort=updated&type=owner" )) .await?; - let arr = value - .as_array() - .ok_or_else(|| "GitHub repos response was not an array".to_string())?; let mut out = Vec::new(); - for repo in arr { + for repo in &arr { let name = repo .get("name") .and_then(|v| v.as_str()) @@ -121,7 +140,7 @@ impl GitHubResolver { out.push(ResolvedChild { url: format!("https://github.com/{full_name}"), title: full_name.clone(), - body: Some(github_json_body(repo)), + body: Some(github_repo_body(repo)), }); } out.sort_by(|a, b| a.url.cmp(&b.url)); @@ -129,16 +148,13 @@ impl GitHubResolver { } async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> { - let value = self - .get_json(&format!( + let arr = self + .get_json_array_pages(&format!( "/repos/{owner}/{repo}/issues?state=open&per_page=100" )) .await?; - let arr = value - .as_array() - .ok_or_else(|| "GitHub issues response was not an array".to_string())?; let mut out = Vec::new(); - for issue in arr { + for issue in &arr { if issue.get("pull_request").is_some() { continue; } @@ -152,7 +168,7 @@ impl GitHubResolver { out.push(ResolvedChild { url: format!("https://github.com/{owner}/{repo}/issues/{number}"), title: format!("#{number} {title}"), - body: Some(github_json_body(issue)), + body: Some(github_issue_body(issue, "issue")), }); } out.sort_by(|a, b| a.url.cmp(&b.url)); @@ -160,16 +176,13 @@ impl GitHubResolver { } async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> { - let value = self - .get_json(&format!( + let arr = self + .get_json_array_pages(&format!( "/repos/{owner}/{repo}/pulls?state=open&per_page=100" )) .await?; - let arr = value - .as_array() - .ok_or_else(|| "GitHub pulls response was not an array".to_string())?; let mut out = Vec::new(); - for pull in arr { + for pull in &arr { let Some(number) = pull.get("number").and_then(|v| v.as_i64()) else { continue; }; @@ -180,7 +193,60 @@ impl GitHubResolver { out.push(ResolvedChild { url: format!("https://github.com/{owner}/{repo}/pulls/{number}"), title: format!("#{number} {title}"), - body: Some(github_json_body(pull)), + body: Some(github_issue_body(pull, "pull request")), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> { + let arr = self + .get_json_array_pages(&format!("/repos/{owner}/{repo}/commits?per_page=100")) + .await?; + let mut out = Vec::new(); + for commit in &arr { + let Some(sha) = github_string(commit, "sha") else { + continue; + }; + let short = sha.chars().take(7).collect::(); + let title = commit + .get("commit") + .and_then(|c| c.get("message")) + .and_then(|v| v.as_str()) + .and_then(|m| m.lines().next()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or("commit"); + let url = github_string(commit, "html_url") + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/commit/{sha}")); + out.push(ResolvedChild { + url, + title: format!("{short} {title}"), + body: Some(github_commit_body(commit)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> { + let arr = self + .get_json_array_pages(&format!("/repos/{owner}/{repo}/releases?per_page=100")) + .await?; + let mut out = Vec::new(); + for release in &arr { + let Some(tag) = github_string(release, "tag_name") else { + continue; + }; + let title = github_string(release, "name").unwrap_or(tag); + let url = github_string(release, "html_url") + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/releases/tag/{tag}")); + out.push(ResolvedChild { + url, + title: title.to_string(), + body: Some(github_release_body(release)), }); } out.sort_by(|a, b| a.url.cmp(&b.url)); @@ -240,11 +306,147 @@ fn sanitize_body(s: &str) -> String { .collect() } -fn github_json_body(value: &Value) -> String { - let json = serde_json::to_string_pretty(value) - .unwrap_or_else(|_| value.to_string()) - .replace("```", "` ` `"); - format!("```json\n{json}\n```") +fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) +} + +fn github_user_login(value: &Value) -> Option<&str> { + value + .get("user") + .and_then(|u| u.get("login")) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) +} + +fn github_labels(value: &Value) -> Vec { + value + .get("labels") + .and_then(|v| v.as_array()) + .into_iter() + .flat_map(|labels| labels.iter()) + .filter_map(|label| label.get("name").and_then(|v| v.as_str())) + .filter(|name| !name.trim().is_empty()) + .map(|name| name.to_string()) + .collect() +} + +fn github_repo_body(repo: &Value) -> String { + let full_name = github_string(repo, "full_name") + .or_else(|| github_string(repo, "name")) + .unwrap_or("GitHub repository"); + let mut lines = vec![full_name.to_string()]; + if let Some(desc) = github_string(repo, "description") { + lines.push(String::new()); + lines.push(desc.to_string()); + } + if let Some(url) = github_string(repo, "html_url") { + lines.push(String::new()); + lines.push(format!("Source: {url}")); + } + if let Some(lang) = github_string(repo, "language") { + lines.push(format!("Language: {lang}")); + } + lines.join("\n") +} + +fn github_issue_body(issue: &Value, kind: &str) -> String { + let number = issue + .get("number") + .and_then(|v| v.as_i64()) + .map(|n| format!("#{n} ")) + .unwrap_or_default(); + let title = github_string(issue, "title").unwrap_or("Untitled"); + let state = github_string(issue, "state").unwrap_or("unknown"); + let mut lines = vec![format!("{kind} {number}{title}")]; + lines.push(format!("State: {state}")); + if let Some(author) = github_user_login(issue) { + lines.push(format!("Author: @{author}")); + } + let labels = github_labels(issue); + if !labels.is_empty() { + lines.push(format!("Labels: {}", labels.join(", "))); + } + if let Some(url) = github_string(issue, "html_url") { + lines.push(format!("Source: {url}")); + } + if let Some(body) = github_string(issue, "body") { + lines.push(String::new()); + lines.push(body.to_string()); + } + lines.join("\n") +} + +fn github_commit_body(commit: &Value) -> String { + let sha = github_string(commit, "sha").unwrap_or("unknown"); + let short = sha.chars().take(7).collect::(); + let commit_obj = commit.get("commit"); + let message = commit_obj + .and_then(|c| c.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("commit"); + let mut lines = vec![format!("commit {short}")]; + if let Some(author) = commit_obj + .and_then(|c| c.get("author")) + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + { + lines.push(format!("Author: {author}")); + } + if let Some(login) = github_user_login(commit) { + lines.push(format!("GitHub user: @{login}")); + } + if let Some(date) = commit_obj + .and_then(|c| c.get("author")) + .and_then(|a| a.get("date")) + .and_then(|v| v.as_str()) + { + lines.push(format!("Date: {date}")); + } + if let Some(url) = github_string(commit, "html_url") { + lines.push(format!("Source: {url}")); + } + lines.push(String::new()); + lines.push(message.to_string()); + lines.join("\n") +} + +fn github_release_body(release: &Value) -> String { + let tag = github_string(release, "tag_name").unwrap_or("untagged"); + let title = github_string(release, "name").unwrap_or(tag); + let mut lines = vec![format!("release {title}")]; + lines.push(format!("Tag: {tag}")); + if release + .get("draft") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + lines.push("Draft: yes".to_string()); + } + if release + .get("prerelease") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + lines.push("Prerelease: yes".to_string()); + } + if let Some(author) = github_user_login(release) { + lines.push(format!("Author: @{author}")); + } + if let Some(published) = github_string(release, "published_at") { + lines.push(format!("Published: {published}")); + } + if let Some(url) = github_string(release, "html_url") { + lines.push(format!("Source: {url}")); + } + if let Some(body) = github_string(release, "body") { + lines.push(String::new()); + lines.push(body.to_string()); + } + lines.join("\n") } fn children_to_dsl(children: &[ResolvedChild]) -> String { @@ -382,4 +584,47 @@ mod tests { assert!(dsl.contains("{\"test\": true}")); assert!(dsl.contains("```\n}\n")); } + + #[test] + fn github_issue_body_is_readable_text_not_json_dump() { + let issue = serde_json::json!({ + "number": 12, + "title": "Render children", + "state": "open", + "html_url": "https://github.com/o/r/issues/12", + "user": {"login": "octo"}, + "labels": [{"name": "bug"}], + "body": "The issue body." + }); + let body = github_issue_body(&issue, "issue"); + assert!(body.contains("issue #12 Render children")); + assert!(body.contains("Author: @octo")); + assert!(body.contains("The issue body.")); + assert!(!body.trim_start().starts_with("```json")); + } + + #[test] + fn github_commit_and_release_bodies_are_readable() { + let commit = serde_json::json!({ + "sha": "abcdef123456", + "html_url": "https://github.com/o/r/commit/abcdef123456", + "author": {"login": "octo"}, + "commit": { + "message": "Fix vote page\n\nDetails here.", + "author": {"name": "Octo Dev", "date": "2026-05-17T00:00:00Z"} + } + }); + let release = serde_json::json!({ + "tag_name": "v1.2.3", + "name": "Release 1.2.3", + "html_url": "https://github.com/o/r/releases/tag/v1.2.3", + "author": {"login": "octo"}, + "prerelease": true, + "body": "Release notes." + }); + assert!(github_commit_body(&commit).contains("commit abcdef1")); + assert!(github_commit_body(&commit).contains("Fix vote page")); + assert!(github_release_body(&release).contains("release Release 1.2.3")); + assert!(github_release_body(&release).contains("Prerelease: yes")); + } } diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index b28271e0316738eae245855b24aeb76486860554..9ca66e7c5860d428e95abf5df518fc1e7b4f6332 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -22,7 +22,7 @@ use crate::{ reducer::{ContentState, ReducerState, ScopeId}, scope_rank::{ build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive, - ChildrenRankings, + suggest_next_pair_in_pool, ChildrenRankings, }, state::AppState, timeago, @@ -237,7 +237,7 @@ pub(crate) async fn vote_compare_post_success_js( state: &AppState, nav: &ThreadNav, _room_wire: &str, - _thread_tag: &str, + thread_tag: &str, left: &ItemId, right: &ItemId, _post_id: &str, @@ -246,9 +246,13 @@ pub(crate) async fn vote_compare_post_success_js( let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); let edge_history = vote_edge_history_markup(content, left, right); + let next_pair = suggest_next_vote_pair(content, left, right); + let nav_markup = + vote_compare_nav_markup(nav, next_pair.as_ref(), left, right, Some(thread_tag)); drop(reduced); JsBuilder::new() .morph_inner_selector("#vote-edge-history-region", edge_history) + .morph_selector(".vote-compare-nav", nav_markup) .build() } @@ -288,6 +292,93 @@ fn vote_compare_href( } } +fn login_href_with_next(next: &str) -> String { + let next = if next.trim().starts_with('/') && !next.trim().starts_with("//") { + next.trim() + } else { + "/" + }; + format!("/login?next={}", urlencoding::encode(next)) +} + +fn vote_compare_nav_markup( + nav: &ThreadNav, + next_pair: Option<&(ItemId, ItemId)>, + left: &ItemId, + right: &ItemId, + thread_override: Option<&str>, +) -> maud::Markup { + let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None)); + let swap_pair_href = vote_compare_href(nav, right, left, thread_override); + html! { + div class="vote-compare-nav" { + @if let Some(href) = &next_pair_href { + a class="vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" } + } @else { + span class="vote-compare-next is-disabled" { "no next pair" } + } + a class="vote-compare-next" href=(swap_pair_href) { "swap sides" } + } + } +} + +fn suggest_next_vote_pair( + content: &ContentState, + current_left: &ItemId, + current_right: &ItemId, +) -> Option<(ItemId, ItemId)> { + let mut pool: Vec = if current_left.parent().as_ref().map(|p| p.as_str()) + == current_right.parent().as_ref().map(|p| p.as_str()) + { + current_left + .parent() + .and_then(|parent| { + content + .item_children + .get(&parent.normalized_storage()) + .cloned() + }) + .map(|children| children.into_iter().collect()) + .unwrap_or_default() + } else { + Vec::new() + }; + if pool.len() < 2 { + pool = content.items.iter().cloned().collect(); + } + suggest_next_pair_in_pool( + &content.ranking_group, + &pool, + Some((current_left, current_right)), + ) +} + +fn vote_compare_item_card( + nav: &ThreadNav, + item: &ItemId, + body: Option<&String>, + side_class: &str, +) -> maud::Markup { + html! { + div class=(format!("vote-compare-side {side_class}")) { + a class=(format!("vote-compare-item {side_class}")) href=(nav.garden_item_href(item)) { + code { (item_display_path(item.as_str())) } + } + @if let Some(body) = body.filter(|b| !b.trim().is_empty()) { + div class="vote-compare-item-body" { + (render_linkified_with_embeds_in_scope( + body, + nav.garden_root_url(), + None, + )) + } + } @else { + p class="muted vote-compare-item-body-empty" { "no body yet" } + } + } + } +} + fn ont_pin_vote_controls( nav: &ThreadNav, current_storage: &str, @@ -1153,7 +1244,7 @@ fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option Option, + next: &str, +) -> maud::Markup { + let next = if next.trim().starts_with('/') && !next.trim().starts_with("//") { + next.trim() + } else { + "/" + }; + html! { + @match imported { + Ok(n) => { + p class="resolver-status-ok" { + @if n == 0 { + "No GitHub children found. " + } @else { + (format!("Imported {n} GitHub item{}.", if n == 1 { "" } else { "s" })) " " + } + a href=(next) { "Refresh page" } + " to render the updated ontology." + } + } + Err(msg) => { + p class="resolver-status-error" { (msg) } + } + } + } +} + async fn render_scope_view( state: AppState, browse: GardenBrowsePath, @@ -1468,6 +1590,9 @@ async fn vote_compare_inner( .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right)); let thread_tags = vote_thread_tags_for_pair(content, &left, &right); let edge_history = vote_edge_history_markup(content, &left, &right); + let left_body = content.item_bodies.get(&left).cloned(); + let right_body = content.item_bodies.get(&right).cloned(); + let next_pair = suggest_next_vote_pair(content, &left, &right); drop(reduced); let title = format!( @@ -1495,52 +1620,51 @@ async fn vote_compare_inner( .expect("vote compare rpc json"); let body = html! { - h2 { "compare" } - div class="vote-compare-pair" { - a class="vote-compare-item" href=(nav.garden_item_href(&left)) { - code { (item_display_path(left.as_str())) } + section class="vote-compare-shell" { + h2 { "compare" } + div class="vote-compare-pair" { + (vote_compare_item_card(&nav, &left, left_body.as_ref(), "vote-compare-left")) + span class="vote-compare-vs" { "vs" } + (vote_compare_item_card(&nav, &right, right_body.as_ref(), "vote-compare-right")) } - span class="vote-compare-vs" { "vs" } - a class="vote-compare-item" href=(nav.garden_item_href(&right)) { - code { (item_display_path(right.as_str())) } + (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref())) + div id="vote-edge-history-region" { + (edge_history) } - } - div id="vote-edge-history-region" { - (edge_history) - } - @if can_post { - form id="vote-compare-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json); - div class="vote-thread-picker" { - label class="vote-thread-picker-label" { "thread" } - select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" { - @if thread_tags.is_empty() { - option value="vote" selected { "#vote" } - } - @for t in &thread_tags { - @if *t == auto_thread { - option value=(t) selected { "#" (t) } - } @else { - option value=(t) { "#" (t) } + @if can_post { + form id="vote-compare-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json); + div class="vote-thread-picker" { + label class="vote-thread-picker-label" { "thread" } + select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" { + @if thread_tags.is_empty() { + option value="vote" selected { "#vote" } + } + @for t in &thread_tags { + @if *t == auto_thread { + option value=(t) selected { "#" (t) } + } @else { + option value=(t) { "#" (t) } + } } } } + input type="hidden" name="ratio_left" id="vote-ratio-left" value="50"; + input type="hidden" name="ratio_right" id="vote-ratio-right" value="50"; + label class="vote-compare-slider-label" { + span id="vote-slider-left-label" { (item_display_path(left.as_str())) } + input type="range" id="vote-preference-slider" min="0" max="100" value="50" + aria-valuemin="0" aria-valuemax="100"; + span id="vote-slider-right-label" { (item_display_path(right.as_str())) } + } + label class="vote-explain-label" { "reason (required)" } + textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {} + div id="vote-compare-errors" {} + p { button type="submit" { "post vote" } } } - input type="hidden" name="ratio_left" id="vote-ratio-left" value="50"; - input type="hidden" name="ratio_right" id="vote-ratio-right" value="50"; - label class="vote-compare-slider-label" { - span id="vote-slider-left-label" { (item_display_path(left.as_str())) } - input type="range" id="vote-preference-slider" min="0" max="100" value="50" - aria-valuemin="0" aria-valuemax="100"; - span id="vote-slider-right-label" { (item_display_path(right.as_str())) } - } - label class="vote-explain-label" { "reason (required)" } - textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {} - div id="vote-compare-errors" {} - p { button type="submit" { "post vote" } } + } @else { + p class="muted" { a href=(login_href_with_next(&next_path)) { "log in" } " to post this vote." } } - } @else { - p class="muted" { a href="/login" { "log in" } " to post this vote." } } }; @@ -1649,6 +1773,42 @@ mod tests { assert_eq!(edge_vote_entries_for_pair(content, &a, &b).len(), 2); } + #[test] + fn suggest_next_vote_pair_prefers_unvoted_sibling_pair() { + use super::{content_for_garden_view, suggest_next_vote_pair}; + use crate::path_types::ItemId; + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "@00000000-0000-0000-0000-000000000000:test:local/test\n\ + ~/topic {root}\n\ + ~/topic/a {alpha}\n\ + ~/topic/b {beta}\n\ + ~/topic/c {gamma}\n\ + {a beats b}\n ~/topic/a 2:1 ~/topic/b\n", + ); + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let a = ItemId::parse("~/topic/a").unwrap().normalized_storage(); + let b = ItemId::parse("~/topic/b").unwrap().normalized_storage(); + let next = suggest_next_vote_pair(content, &a, &b).expect("next sibling pair"); + assert_ne!( + super::canonical_edge_items(&next.0, &next.1), + super::canonical_edge_items(&a, &b) + ); + assert!( + next.0.as_str().ends_with("/c") || next.1.as_str().ends_with("/c"), + "next pair should include the unvoted sibling: {next:?}" + ); + } + + #[test] + fn external_resolver_status_markup_reports_success_and_refresh() { + let html = super::external_resolver_status_markup(Ok(2), "/-/github.com/o/r").into_string(); + assert!(html.contains("Imported 2 GitHub items.")); + assert!(html.contains("href=\"/-/github.com/o/r\"")); + } + #[test] fn item_page_model_includes_body_and_unranked_without_votes() { let mut reduced = ReducerState::default(); diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index d5877eeb3632ed5d770f23a04cb4d1881209bc74..a1b929625acbd5298c0cf62f3ca0892079edcb2a 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -36,7 +36,10 @@ pub(crate) use forum::{ thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post, user_can_post_room, user_can_view_room, }; -pub(crate) use garden::{encode_pin_cookie_value, vote_compare_post_success_js, GARDEN_PIN_COOKIE}; +pub(crate) use garden::{ + encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js, + GARDEN_PIN_COOKIE, +}; pub use garden::{ external_garden_index, external_ontology_path, garden_index, ontology_path, room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path, diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs index 4fdaae150c74ec5162c2accc06d4cc778ad79914..06c560b8eff09b34896d3935d3917fb28f602bc6 100644 --- a/server/src/scope_rank.rs +++ b/server/src/scope_rank.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use crate::path_types::ItemId; use crate::ranking::{connected_components_from_voted_pairs, ranked_items_subset, RankedItem}; -use crate::reducer::ContentState; +use crate::reducer::{ContentState, GroupState}; #[derive(Debug, Clone)] pub struct ScopedComponent { @@ -49,15 +49,16 @@ pub fn resolve_scope(content: &ContentState, specs: &[String]) -> Vec { /// Resolve scope specs recursively up to `depth` levels deep. /// depth=1 is equivalent to resolve_scope (direct children only). /// depth=2 includes grandchildren, etc. -pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth: usize) -> Vec { +pub fn resolve_scope_recursive( + content: &ContentState, + specs: &[String], + depth: usize, +) -> Vec { if depth == 0 { return vec![]; } let mut visited: HashSet = HashSet::new(); - let mut frontier: Vec = specs - .iter() - .filter_map(|s| ItemId::parse(s)) - .collect(); + let mut frontier: Vec = specs.iter().filter_map(|s| ItemId::parse(s)).collect(); for _level in 0..depth { let mut next_frontier: Vec = Vec::new(); @@ -83,7 +84,10 @@ pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth: /// Build connected-component rankings for an explicit set of item paths. /// Use this when scope comes from multiple parents (resolve_scope). -pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[ItemId]) -> ChildrenRankings { +pub fn build_rankings_for_item_set( + content: &ContentState, + items_in_scope: &[ItemId], +) -> ChildrenRankings { let group = &content.ranking_group; let mut items_in_scope: Vec = items_in_scope.to_vec(); items_in_scope.sort(); @@ -158,6 +162,67 @@ pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> Child build_rankings_for_item_set(content, &items) } +pub fn is_pair_voted_in_group(group: &GroupState, a: &ItemId, b: &ItemId) -> bool { + let Some(&a_idx) = group.item_to_idx.get(a) else { + return false; + }; + let Some(&b_idx) = group.item_to_idx.get(b) else { + return false; + }; + let (i, j) = if a_idx < b_idx { + (a_idx, b_idx) + } else { + (b_idx, a_idx) + }; + group.voted_pairs.contains(&(i, j)) +} + +fn canonical_pair(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) { + let ac = a.clone().normalized_storage(); + let bc = b.clone().normalized_storage(); + if ac.as_str() <= bc.as_str() { + (ac, bc) + } else { + (bc, ac) + } +} + +pub fn suggest_next_pair_in_pool( + group: &GroupState, + pool: &[ItemId], + current_pair: Option<(&ItemId, &ItemId)>, +) -> Option<(ItemId, ItemId)> { + let current = current_pair.map(|(a, b)| canonical_pair(a, b)); + let mut pool = pool.to_vec(); + pool.sort(); + pool.dedup(); + if pool.len() < 2 { + return None; + } + + for i in 0..pool.len() { + for j in (i + 1)..pool.len() { + let pair = canonical_pair(&pool[i], &pool[j]); + if current.as_ref() == Some(&pair) { + continue; + } + if !is_pair_voted_in_group(group, &pool[i], &pool[j]) { + return Some((pool[i].clone(), pool[j].clone())); + } + } + } + + for i in 0..pool.len() { + for j in (i + 1)..pool.len() { + let pair = canonical_pair(&pool[i], &pool[j]); + if current.as_ref() != Some(&pair) { + return Some((pool[i].clone(), pool[j].clone())); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -167,10 +232,7 @@ mod tests { let mut item_children: HashMap> = HashMap::new(); for (parent, children) in edges { let parent = ItemId::parse(parent).unwrap(); - let set: HashSet = children - .iter() - .map(|s| ItemId::parse(s).unwrap()) - .collect(); + let set: HashSet = children.iter().map(|s| ItemId::parse(s).unwrap()).collect(); item_children.insert(parent, set); } ContentState { @@ -187,9 +249,13 @@ mod tests { #[test] fn resolve_one_scope_literal() { - let content = content_with_children(&[ - ("https://slug.social/models", &["https://slug.social/models/x", "https://slug.social/models/y"]), - ]); + let content = content_with_children(&[( + "https://slug.social/models", + &[ + "https://slug.social/models/x", + "https://slug.social/models/y", + ], + )]); let out = resolve_one_scope(&content, "models"); assert_eq!(out.len(), 2); assert!(out.contains(&ItemId::parse("https://slug.social/models/x").unwrap())); @@ -199,8 +265,14 @@ mod tests { #[test] fn resolve_scope_multiple_parents_merges() { let content = content_with_children(&[ - ("https://slug.social/a", &["https://slug.social/a/1", "https://slug.social/a/2"]), - ("https://slug.social/b", &["https://slug.social/b/1", "https://slug.social/b/2"]), + ( + "https://slug.social/a", + &["https://slug.social/a/1", "https://slug.social/a/2"], + ), + ( + "https://slug.social/b", + &["https://slug.social/b/1", "https://slug.social/b/2"], + ), ]); let out = resolve_scope(&content, &["a".into(), "b".into()]); assert_eq!(out.len(), 4); @@ -209,4 +281,28 @@ mod tests { assert!(out.contains(&ItemId::parse("https://slug.social/b/1").unwrap())); assert!(out.contains(&ItemId::parse("https://slug.social/b/2").unwrap())); } + + #[test] + fn suggest_next_pair_skips_current_and_voted_pairs() { + let mut group = crate::reducer::GroupState::new(); + let a = ItemId::parse("~/a").unwrap().normalized_storage(); + let b = ItemId::parse("~/b").unwrap().normalized_storage(); + let c = ItemId::parse("~/c").unwrap().normalized_storage(); + group.apply_vote(crate::reducer::VoteData { + ts: 1, + a: a.clone(), + b: b.clone(), + ratio_left: 2, + ratio_right: 1, + body: "a beats b".to_string(), + principal: "tester".to_string(), + delegate: None, + thread_tag: "vote".to_string(), + }); + let next = + suggest_next_pair_in_pool(&group, &[a.clone(), b.clone(), c.clone()], Some((&a, &b))) + .expect("next pair"); + assert!(next.0 == c || next.1 == c); + assert_ne!(canonical_pair(&next.0, &next.1), canonical_pair(&a, &b)); + } } diff --git a/server/src/state.rs b/server/src/state.rs index 8a80fed0a6bc7ae3516c76ab9d1e8592d7038ad1..48298e2e66456268d23a6462536eb32bfeb5f29b 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -27,6 +27,8 @@ pub struct PendingSession { pub provider_id: Option, /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`]. pub redeem_invite: Option, + /// Local path to navigate to after browser onboarding completes. + pub redirect_next: Option, pub complete: Option<(String /*username*/, String /*bearer*/)>, } diff --git a/server/static/theme_default.css b/server/static/theme_default.css index fdcde86c718eb53cce8273e45a9844c2d8041f88..184e11a590e7019773f7f0abfa41e79161556c71 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -930,6 +930,34 @@ button.ont-garden-pin-ico:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; } +.resolver-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.resolver-refresh-link, +.vote-compare-next { + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--ui); + font-size: 12px; + padding: 4px 10px; + text-decoration: none; +} +.resolver-refresh-link:hover, +.vote-compare-next:hover { + color: var(--signal); +} +.resolver-status { + margin-top: 8px; +} +.resolver-status p { + margin: 0; +} +.resolver-status-error { + color: var(--danger, #b00020); +} body.view-ontology-light ol.ont-ranking-list li, body.view-ontology-light ul.ont-group-list li { display: flex; @@ -959,15 +987,19 @@ body.view-vote-compare .vote-compare-shell > h2 { color: var(--meta); } .vote-compare-pair { - display: flex; - flex-wrap: wrap; - align-items: center; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: start; gap: 10px 16px; - margin: 10px 0; + margin: 0 0 10px; } .vote-compare-item { + display: inline-block; text-decoration: none; } +.vote-compare-right { + text-align: right; +} .vote-compare-item code { font-size: 13px; } @@ -980,6 +1012,33 @@ body.view-vote-compare .vote-compare-shell > h2 { letter-spacing: 0.1em; text-transform: uppercase; } +.vote-compare-item-body { + margin-top: 8px; + max-height: 42dvh; + overflow: auto; +} +.vote-compare-item-body pre { + background: var(--g1); + font-size: 13px; + line-height: 1.35; + padding: 8px 10px; +} +.vote-compare-item-body-empty { + font-size: 12px; + margin: 8px 0 0; +} +.vote-compare-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin: 12px 0; +} +.vote-compare-next.is-disabled { + color: var(--meta); + cursor: default; + opacity: 0.65; +} .vote-compare-slider-label { display: flex; flex-wrap: wrap; @@ -1060,18 +1119,27 @@ body.view-vote-compare-fullscreen { width: 100%; margin-left: 0; margin-right: 0; - padding: clamp(8px, 2.5vw, 28px); - padding-bottom: max(20px, env(safe-area-inset-bottom, 0px)); - padding-top: max(8px, env(safe-area-inset-top, 0px)); + padding: 0; min-height: 100vh; min-height: 100dvh; box-sizing: border-box; } +body.view-vote-compare-fullscreen > .view-meta { + bottom: 4px; + position: fixed; + right: 6px; + z-index: 20; +} body.view-vote-compare-fullscreen .vote-compare-shell { max-width: none; width: 100%; box-sizing: border-box; margin: 0; + min-height: 100dvh; + padding: 0; +} +body.view-vote-compare-fullscreen .vote-compare-shell > h2 { + display: none; } .vote-edge-history-title { diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css index 61e1448b2f66a075c0e33325d6980448712fc927..6747f59eb1ec5c335029fe92d4e5c55b3125a210 100644 --- a/server/static/theme_retro.css +++ b/server/static/theme_retro.css @@ -135,6 +135,34 @@ body.view-ontology nav.breadcrumb a:hover { body.view-ontology nav.breadcrumb a.bc-current { color: #111; font-weight: 600; } body.view-ontology nav.breadcrumb .bc-sep { color: #888; padding: 0 2px; } +body.view-ontology .resolver-actions, +body.view-ontology .vote-compare-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +body.view-ontology .resolver-refresh-link, +body.view-ontology .vote-compare-next { + border: 1px solid #bbb; + color: #23a; + padding: 0.2rem 0.55rem; + text-decoration: none; +} +body.view-ontology .vote-compare-next.is-disabled { + color: #666; + opacity: 0.65; +} +body.view-ontology .resolver-status { + margin-top: 0.5rem; +} +body.view-ontology .resolver-status p { + margin: 0; +} +body.view-ontology .resolver-status-error { + color: #9b1c1c; +} + body.view-ontology ol.ont-ranking-list { counter-reset: ont-rank; list-style: none; @@ -205,16 +233,48 @@ body.view-vote-compare-fullscreen { max-width: none; width: 100%; margin: 0; - padding: clamp(8px, 2.5vw, 20px); - padding-bottom: max(24px, env(safe-area-inset-bottom, 0px)); - padding-top: max(8px, env(safe-area-inset-top, 0px)); + padding: 0; min-height: 100vh; min-height: 100dvh; box-sizing: border-box; } +body.view-vote-compare-fullscreen > .view-meta { + bottom: 4px; + position: fixed; + right: 6px; + z-index: 20; +} body.view-vote-compare-fullscreen .vote-compare-shell { max-width: none; width: 100%; box-sizing: border-box; margin: 0; + min-height: 100dvh; + padding: 0; +} +body.view-vote-compare-fullscreen .vote-compare-shell > h2 { + display: none; +} +body.view-ontology .vote-compare-pair { + align-items: start; + display: grid; + gap: 0.65rem 1rem; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + margin: 0 0 0.75rem; +} +body.view-ontology .vote-compare-item { + display: inline-block; +} +body.view-ontology .vote-compare-right { + text-align: right; +} +body.view-ontology .vote-compare-item-body { + margin-top: 0.45rem; + max-height: 42dvh; + overflow: auto; +} +body.view-ontology .vote-compare-item-body pre { + background: #faf8f3; + border: 1px solid #ccc; + padding: 0.5rem 0.65rem; } diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 7da102040484c887833158a37c307d078205c701..55d984a6dd70ebeadeca7baef86b844955f1d78c 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -742,6 +742,34 @@ body.view-ontology button.ont-garden-pin-ico:focus-visible { outline-offset: 2px; } +body.view-ontology .resolver-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +body.view-ontology .resolver-refresh-link { + border: 1px solid #c8c4bc; + color: #3d3a34; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.25rem 0.55rem; + text-decoration: none; +} +body.view-ontology .resolver-refresh-link:hover { + border-color: #a68e6b; + color: #1a1814; +} +body.view-ontology .resolver-status { + margin-top: 0.5rem; +} +body.view-ontology .resolver-status p { + margin: 0; +} +body.view-ontology .resolver-status-error { + color: #8b1f1f; +} + body.view-ontology ol.ont-ranking-list { counter-reset: ont-rank; list-style: none; @@ -809,21 +837,26 @@ body.view-ontology.view-vote-compare .vote-compare-shell { body.view-ontology.view-vote-compare.view-vote-compare-fullscreen .vote-compare-shell { max-width: none; width: 100%; - min-height: calc(100dvh - clamp(20px, 5vw, 56px)); + min-height: 100dvh; margin: 0; + padding: 0; box-sizing: border-box; } body.view-vote-compare-fullscreen { max-width: none !important; width: 100%; margin: 0 !important; - padding: clamp(6px, 2vw, 1.75rem); - padding-bottom: max(1.25rem, env(safe-area-inset-bottom, 0px)); - padding-top: max(6px, env(safe-area-inset-top, 0px)); + padding: 0; min-height: 100vh; min-height: 100dvh; box-sizing: border-box; } +body.view-vote-compare-fullscreen > .view-meta { + bottom: 0.25rem; + position: fixed; + right: 0.35rem; + z-index: 20; +} body.view-ontology.view-vote-compare .vote-compare-shell > h2 { margin-top: 0; font-size: 0.7rem; @@ -831,12 +864,15 @@ body.view-ontology.view-vote-compare .vote-compare-shell > h2 { text-transform: uppercase; color: #5c574e; } +body.view-ontology.view-vote-compare-fullscreen .vote-compare-shell > h2 { + display: none; +} body.view-ontology .vote-compare-pair { - display: flex; - flex-wrap: wrap; - align-items: center; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: start; gap: 0.65rem 1rem; - margin: 0.65rem 0 0.85rem; + margin: 0 0 0.85rem; } body.view-ontology .vote-compare-vs { color: #8a857a; @@ -844,14 +880,62 @@ body.view-ontology .vote-compare-vs { font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; + padding-top: 0.18rem; } body.view-ontology .vote-compare-item { + display: inline-block; text-decoration: none; } +body.view-ontology .vote-compare-right { + text-align: right; +} body.view-ontology .vote-compare-item:hover code { border-color: #a68e6b; background: #f0ebe3; } +body.view-ontology .vote-compare-item-body { + margin-top: 0.45rem; + max-height: 42dvh; + overflow: auto; +} +body.view-ontology .vote-compare-item-body pre { + background: #fdfcfa; + border: 1px solid #d4cfc4; + border-left: 3px solid #a68e6b; + color: #1a1814; + font-size: 0.82rem; + line-height: 1.35; + padding: 0.55rem 0.65rem; +} +body.view-ontology .vote-compare-item-body-empty { + font-size: 0.78rem; + margin: 0.45rem 0 0; +} +body.view-ontology .vote-compare-nav { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + justify-content: center; + margin: 0.75rem 0; +} +body.view-ontology .vote-compare-next { + border: 1px solid #c8c4bc; + color: #3d3a34; + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.04em; + padding: 0.25rem 0.65rem; + text-decoration: none; +} +body.view-ontology .vote-compare-next:hover { + border-color: #a68e6b; + color: #1a1814; +} +body.view-ontology .vote-compare-next.is-disabled { + color: #8a857a; + cursor: default; + opacity: 0.65; +} body.view-ontology .vote-thread-picker { margin: 0.85rem 0; diff --git a/server/tests/integration.rs b/server/tests/integration.rs index 3c9f0476c58abdc947fd50424bfea66a784fb83b..fb0b9335440181d4d50104d37926b0b2eeeb602a 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -8,9 +8,9 @@ use slugsocial_server::{ spawn_writer_actor_for_test, state::{AppConfig, AppState}, }; +use std::net::SocketAddr; use tempfile::TempDir; use tokio::net::TcpListener; -use std::net::SocketAddr; fn sha256_hex(s: &str) -> String { let mut hasher = Sha256::new(); @@ -94,7 +94,13 @@ async fn seed_test_token(state: &AppState) { r.apply_event(ev); } -async fn create_test_server_with_state() -> (SocketAddr, TempDir, EventLog, AppState, tokio::task::JoinHandle<()>) { +async fn create_test_server_with_state() -> ( + SocketAddr, + TempDir, + EventLog, + AppState, + tokio::task::JoinHandle<()>, +) { let tmp = TempDir::new().unwrap(); let log_path = tmp.path().join("events.jsonl"); let log = EventLog::new(&log_path); @@ -131,7 +137,11 @@ async fn create_test_server() -> (SocketAddr, TempDir, EventLog, tokio::task::Jo async fn test_healthz() { let (addr, _tmp, _log, _handle) = create_test_server().await; let client = reqwest::Client::new(); - let response = client.get(&format!("http://{}/healthz", addr)).send().await.unwrap(); + let response = client + .get(&format!("http://{}/healthz", addr)) + .send() + .await + .unwrap(); assert!(response.status().is_success()); assert_eq!(response.text().await.unwrap(), "ok"); } @@ -189,7 +199,13 @@ async fn test_room_delete_rpc() { del["results"][0] ); - let list = rpc_batch(&client, addr, Some(&bearer), serde_json::json!(["RoomList"])).await; + let list = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!(["RoomList"]), + ) + .await; let rooms = list["results"][0]["result"]["RoomList"]["rooms"] .as_array() .unwrap(); @@ -278,7 +294,11 @@ async fn test_private_room_forum_read_requires_bearer() { ) .await; let line_na = &no_auth["results"][0]; - assert_eq!(line_na["ok"], false, "expected failure without bearer: {:?}", line_na); + assert_eq!( + line_na["ok"], false, + "expected failure without bearer: {:?}", + line_na + ); assert_eq!(line_na["error"], "room not found"); let with_auth = rpc_batch( @@ -300,7 +320,11 @@ async fn test_private_room_forum_read_requires_bearer() { ) .await; let line_ok = &with_auth["results"][0]; - assert_eq!(line_ok["ok"], true, "expected success with bearer: {:?}", line_ok); + assert_eq!( + line_ok["ok"], true, + "expected success with bearer: {:?}", + line_ok + ); let total = line_ok["result"]["ForumThread"]["total"].as_u64().unwrap(); assert!(total >= 1); } @@ -499,7 +523,11 @@ async fn test_feed_since_last_post_is_scoped_to_delegate() { ) .await; let steal_line = &steal["results"][0]; - assert_eq!(steal_line["ok"], false, "expected rejection for unbound delegate: {:?}", steal_line); + assert_eq!( + steal_line["ok"], false, + "expected rejection for unbound delegate: {:?}", + steal_line + ); assert_eq!(steal_line["error"], "not your delegate"); let no_auth = rpc_batch( @@ -852,24 +880,24 @@ async fn test_choose_username_returns_evalable_js() { { let mut sessions = state.pending_sessions.write().await; - let pending = sessions.get_mut(session).expect("pending session must exist"); + let pending = sessions + .get_mut(session) + .expect("pending session must exist"); pending.provider = Some("google".to_string()); pending.provider_id = Some("google-user-123".to_string()); } let choose = client .post(format!("http://{addr}/auth/choose-username")) - .form(&[ - ("session", session), - ("username", "webuser"), - ]) + .form(&[("session", session), ("username", "webuser")]) .send() .await .unwrap(); assert_eq!(choose.status(), reqwest::StatusCode::OK); assert_eq!( - choose.headers() + choose + .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()), Some("text/javascript; charset=utf-8") @@ -880,6 +908,48 @@ async fn test_choose_username_returns_evalable_js() { assert!(body.contains("window.location = \"/auth/complete\"")); } +#[tokio::test] +async fn test_choose_username_carries_redirect_next() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let start = client + .post(format!("http://{addr}/api/v0/pending-session")) + .json(&serde_json::json!({ + "agent": "00000000-0000-0000-0000-000000000123:test:web/form" + })) + .send() + .await + .unwrap(); + assert!(start.status().is_success()); + let start_json: serde_json::Value = start.json().await.unwrap(); + let session = start_json["session"].as_str().unwrap(); + + { + let mut sessions = state.pending_sessions.write().await; + let pending = sessions + .get_mut(session) + .expect("pending session must exist"); + pending.provider = Some("google".to_string()); + pending.provider_id = Some("google-user-redirect".to_string()); + pending.redirect_next = Some("/vote/compare?left=%7E%2Fa&right=%7E%2Fb".to_string()); + } + + let choose = client + .post(format!("http://{addr}/auth/choose-username")) + .form(&[("session", session), ("username", "redirectuser")]) + .send() + .await + .unwrap(); + + assert_eq!(choose.status(), reqwest::StatusCode::OK); + let body = choose.text().await.unwrap(); + assert!(body.contains("window.location = \"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\"")); +} + #[tokio::test] async fn test_sse_stream_emits_evalable_js_after_post() { let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await; @@ -903,7 +973,10 @@ async fn test_sse_stream_emits_evalable_js_after_post() { let room_path = format!("/r/{room_seg}"); let sse_resp = client - .get(format!("http://{addr}/sse?path={}", urlencoding::encode(&room_path))) + .get(format!( + "http://{addr}/sse?path={}", + urlencoding::encode(&room_path) + )) .send() .await .unwrap(); @@ -1153,7 +1226,9 @@ async fn test_post_redact_removes_garden_and_marks_thread() { .await; let ra_line = &rank_after["results"][0]; assert_eq!(ra_line["ok"], true); - let comps = ra_line["result"]["GardenRank"]["components"].as_array().unwrap(); + let comps = ra_line["result"]["GardenRank"]["components"] + .as_array() + .unwrap(); assert!( comps.is_empty() || comps[0]["ranking"].as_array().unwrap().is_empty(), "votes from redacted post should be removed: {:?}", @@ -1267,7 +1342,9 @@ async fn test_check_endpoint_does_not_commit() { let threads_body = rpc_batch(&client, addr, None, list_batch).await; let tline = &threads_body["results"][0]; assert_eq!(tline["ok"], true); - let threads = tline["result"]["ForumThreads"]["threads"].as_array().unwrap(); + let threads = tline["result"]["ForumThreads"]["threads"] + .as_array() + .unwrap(); assert!(threads.is_empty()); } @@ -1316,7 +1393,11 @@ async fn test_garden_item_pair_matchup_include_threads() { .iter() .filter_map(|t| t.as_str()) .collect(); - assert!(threads.contains(&"sorting-hat"), "item threads should contain sorting-hat: {:?}", threads); + assert!( + threads.contains(&"sorting-hat"), + "item threads should contain sorting-hat: {:?}", + threads + ); let pair_body = rpc_batch( &client, @@ -1337,7 +1418,11 @@ async fn test_garden_item_pair_matchup_include_threads() { .iter() .filter_map(|t| t.as_str()) .collect(); - assert!(pair_threads.contains(&"sorting-hat"), "pair threads should contain sorting-hat: {:?}", pair_threads); + assert!( + pair_threads.contains(&"sorting-hat"), + "pair threads should contain sorting-hat: {:?}", + pair_threads + ); let matchup_body = rpc_batch( &client, @@ -1429,7 +1514,11 @@ async fn test_garden_pair_and_rank_path_not_found_consistent() { ) .await; let pair2_line = &pair2["results"][0]; - assert_eq!(pair2_line["ok"], true, "GetPair after parent materialized: {:?}", pair2_line); + assert_eq!( + pair2_line["ok"], true, + "GetPair after parent materialized: {:?}", + pair2_line + ); assert!(pair2_line["result"]["Pair"].is_object()); } @@ -1490,7 +1579,11 @@ async fn test_view_counts_increment_and_display() { .send() .await .unwrap(); - assert!(v1.status().is_success(), "vote compare GET 1: {}", v1.status()); + assert!( + v1.status().is_success(), + "vote compare GET 1: {}", + v1.status() + ); let v1_body = v1.text().await.unwrap(); assert!( v1_body.contains("1 views"), @@ -1502,7 +1595,11 @@ async fn test_view_counts_increment_and_display() { .send() .await .unwrap(); - assert!(v2.status().is_success(), "vote compare GET 2: {}", v2.status()); + assert!( + v2.status().is_success(), + "vote compare GET 2: {}", + v2.status() + ); let v2_body = v2.text().await.unwrap(); assert!( v2_body.contains("2 views"), @@ -1576,13 +1673,18 @@ async fn test_rank_history() { let history = resp["history"].as_array().unwrap(); assert_eq!(history.len(), 1, "one ingest → one history entry"); let entry = &history[0]; - assert_eq!(entry["scope_rank"], 1, "rust should be #1 in scope after first ingest"); - assert_eq!(entry["scope_rank_delta"], 0, "delta is 0 on first appearance"); + assert_eq!( + entry["scope_rank"], 1, + "rust should be #1 in scope after first ingest" + ); + assert_eq!( + entry["scope_rank_delta"], 0, + "delta is 0 on first appearance" + ); let caused_by = entry["caused_by"].as_array().unwrap(); assert_eq!(caused_by.len(), 2, "both votes in the ingest touched rust"); assert_eq!( - entry["thread_post_index"], - 0, + entry["thread_post_index"], 0, "rank history links use same 0-based index as /t/hist-test/0" ); @@ -1610,16 +1712,16 @@ async fn test_rank_history() { let caused_by2 = hist2[1]["caused_by"].as_array().unwrap(); assert_eq!(caused_by2.len(), 1); - assert!(caused_by2[0]["a"].as_str().unwrap().ends_with("python") || - caused_by2[0]["b"].as_str().unwrap().ends_with("python")); + assert!( + caused_by2[0]["a"].as_str().unwrap().ends_with("python") + || caused_by2[0]["b"].as_str().unwrap().ends_with("python") + ); assert_eq!( - hist2[0]["thread_post_index"], - 0, + hist2[0]["thread_post_index"], 0, "first hist-test post is chronological index 0" ); assert_eq!( - hist2[1]["thread_post_index"], - 1, + hist2[1]["thread_post_index"], 1, "second ingest is chronological index 1" ); @@ -1675,12 +1777,27 @@ async fn pair_returns_connectivity_stats() { let pair = &pair_body["results"][0]["result"]["Pair"]; let conn = &pair["connectivity"]; - assert!(!conn.is_null(), "pair response should include connectivity stats"); + assert!( + !conn.is_null(), + "pair response should include connectivity stats" + ); assert_eq!(conn["items"].as_u64().unwrap(), 4, "4 items in scope"); - assert_eq!(conn["components"].as_u64().unwrap(), 3, "3 components (1 connected + 2 isolates)"); - assert_eq!(conn["comparisons_until_connected"].as_u64().unwrap(), 2, "need 2 more comparisons"); + assert_eq!( + conn["components"].as_u64().unwrap(), + 3, + "3 components (1 connected + 2 isolates)" + ); + assert_eq!( + conn["comparisons_until_connected"].as_u64().unwrap(), + 2, + "need 2 more comparisons" + ); assert_eq!(conn["pairs_voted"].as_u64().unwrap(), 1, "1 pair voted"); - assert_eq!(conn["pairs_possible"].as_u64().unwrap(), 6, "4*3/2 = 6 possible pairs"); + assert_eq!( + conn["pairs_possible"].as_u64().unwrap(), + 6, + "4*3/2 = 6 possible pairs" + ); let doc2 = serde_json::json!([{ "Post": { @@ -1693,8 +1810,7 @@ async fn pair_returns_connectivity_stats() { }]); let resp2 = rpc_batch(&client, addr, Some(&test_bearer()), doc2).await; assert_eq!( - resp2["results"][0]["ok"], - true, + resp2["results"][0]["ok"], true, "second ingest failed: {:?}", resp2 ); @@ -1713,8 +1829,19 @@ async fn pair_returns_connectivity_stats() { .await; let pair2 = &pair_body2["results"][0]["result"]["Pair"]; let conn2 = &pair2["connectivity"]; - assert_eq!(conn2["components"].as_u64().unwrap(), 2, "2 components after connecting c"); - assert_eq!(conn2["comparisons_until_connected"].as_u64().unwrap(), 1, "1 more comparison to connect"); - assert_eq!(conn2["pairs_voted"].as_u64().unwrap(), 2, "2 pairs voted now"); + assert_eq!( + conn2["components"].as_u64().unwrap(), + 2, + "2 components after connecting c" + ); + assert_eq!( + conn2["comparisons_until_connected"].as_u64().unwrap(), + 1, + "1 more comparison to connect" + ); + assert_eq!( + conn2["pairs_voted"].as_u64().unwrap(), + 2, + "2 pairs voted now" + ); } - diff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj index 1d5b6f1c4575e67ec430e4ce7f02c1bca5a35775..fee272099457ab1f4478fff9aed0f5c08ea2e85e 100644 --- a/test/browser_github_resolver.clj +++ b/test/browser_github_resolver.clj @@ -21,6 +21,18 @@ (do (Thread/sleep 200) (recur)) false)))))) +(defn- wait-for-http-text [url expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [text (try + (:body (oauth/http-get url)) + (catch Exception _ nil))] + (if (and (string? text) (str/includes? text expected)) + true + (if (< (System/currentTimeMillis) deadline) + (do (Thread/sleep 200) (recur)) + false)))))) + (defn- start-mock-github [port] (let [!paths (atom []) handler (fn [req] @@ -112,12 +124,21 @@ (locator/click (page/locator pg "[data-testid=\"github-resolve-children\"]")) (is (wait-for-text pg "body" "-/github.com/octo/hello/pulls" 15000) "repo resolver imports structural children") + (is (wait-for-text pg "body" "-/github.com/octo/hello/commits" 15000) + "repo resolver imports commits section") + (is (wait-for-text pg "body" "-/github.com/octo/hello/releases" 15000) + "repo resolver imports releases section") (page/navigate pg (str base-url "/-/github.com/octo/hello/issues/42")) (locator/click (page/locator pg "[data-testid=\"github-resolve-siblings\"]")) - (page/navigate pg (str base-url "/-/github.com/octo/hello/issues")) - (is (wait-for-text pg "body" "-/github.com/octo/hello/issues/43" 15000) - "issue sibling resolver imports issue siblings"))))) + (is (wait-for-http-text (str base-url "/-/github.com/octo/hello/issues") + "-/github.com/octo/hello/issues/43" + 15000) + "issue sibling resolver persisted issue siblings") + (core/with-page [pg2 (core/new-page-from-context ctx)] + (page/navigate pg2 (str base-url "/-/github.com/octo/hello/issues")) + (is (wait-for-text pg2 "body" "-/github.com/octo/hello/issues/43" 15000) + "issue sibling resolver imports issue siblings")))))) (is (some #{"/users/octo/repos"} @(:paths @!github)) "mock GitHub saw user repos request") diff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj index f97a4a45e89c388f093694a1bf296f674bcb377a..7cd1e467fdd8a4480df1be2afd78adb45b9cdc6d 100644 --- a/test/browser_vote_compare.clj +++ b/test/browser_vote_compare.clj @@ -50,9 +50,11 @@ thread-tag "browser-vote-compare" left-url "https://slug.social/~/gp-vote-a" right-url "https://slug.social/~/gp-vote-b" + third-url "https://slug.social/~/gp-vote-c" raw (str "# " thread-tag "\n\n" "~/gp-vote-a {one}\n" "~/gp-vote-b {two}\n" + "~/gp-vote-c {three}\n" "{seed edge vote}\n" "~/gp-vote-a 1:1 ~/gp-vote-b\n") post-resp (oauth/http-post-json @@ -64,7 +66,7 @@ :headers {"Authorization" (str "Bearer " alice-token)}) post-json (json/parse-string (:body post-resp) false) _ (is (true? (get-in post-json ["results" 0 "ok"])) "seed items + edge vote via rpc") - cmp-url (str base-url "/vote/compare?left=" (enc left-url) "&right=" (enc right-url))] + cmp-url (str base-url "/vote/compare?left=" (enc left-url) "&right=" (enc third-url))] (core/with-playwright [pw] (core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})] (core/with-context [ctx (core/new-context browser)] @@ -74,12 +76,17 @@ (page/navigate pg cmp-url) ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead. (is (wait-for-text pg "body.view-vote-compare" "compare" 15000) "vote compare page") - (is (wait-for-text pg "ul.vote-edge-history" "seed edge vote" 15000) - "edge history lists canonical-order vote") + (is (wait-for-text pg "#vote-edge-history-region" "no votes on this pair" 15000) + "current pair starts without edge history") (locator/fill (page/locator pg "#vote-explain") "because playwright says so") (locator/click (page/locator pg "#vote-compare-form button[type=submit]")) (is (wait-for-text pg "ul.vote-edge-history" "because playwright" 20000) - "new vote appears in edge history after morph")))))) + "new vote appears in edge history after morph") + (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]")) + (is (wait-for-text pg ".vote-compare-pair" "~/gp-vote-b" 15000) + "post-success next-pair nav points to a different pair") + (is (wait-for-text pg ".vote-compare-pair" "~/gp-vote-c" 15000) + "next pair keeps the unvoted third item")))))) (finally (when-some [s @!server] (common/kill-server s)) Side B — contributor: tommy-mor Side B — commit message: [e8fdeacf] nice Side B — unified diff (full patch): diff --git a/.gitignore b/.gitignore index 73e8f22cf0d39c706e7cdce5e39f1903a0f9181b..4c7073f9fac0c30fd2050d79a60ef447af58ebeb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .DS_Store data/ repomix-output.xml +dev-data/ diff --git a/bb.edn b/bb.edn index 1e8ec120921761a9a4f28f36578efb1567407cb3..43cc60c9461466e0f7cb08cfea5c600edf95a662 100644 --- a/bb.edn +++ b/bb.edn @@ -9,11 +9,10 @@ :task (do (deref (p/process ["mkdir" "-p" "dev-data"] {:inherit true})) (deref (p/process ["cargo" "watch" - "-x" "run -p server" + "-x" "run -p sorter2-server" "-w" "server/"] {:inherit true :env (merge (into {} (System/getenv)) - {"SLUG_DATA_DIR" "dev-data" - "SLUG_KEYS" "dev:dev" - "PORT" "8080" - "RUST_LOG" "info"})})))}}} + {"SORTER2_DATA_DIR" "dev-data" + "PORT" "8080" + "RUST_LOG" "info"})})))}}} diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 322dffefeaa3b8560c1c2b2f70ba8e5a8c555022..0acba3ac745f23be473243e0a9b9cc6e57e8d86f 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -232,7 +232,8 @@ pub fn input_panel(query: &str, error: Option<&str>) -> Markup { html! { section id="parser-panel" class="demo-panel" { form method="post" action="/ui" id="parser-form" { - textarea + input + type="text" name="query" id="parser-input" rows="3" diff --git a/server/static/sorter.css b/server/static/sorter.css index 04f4fabdea3de7c3bc54805d0d3d9e0cea6eeefd..a648e88a57290346b1069868134a212b180def52 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -34,29 +34,20 @@ body { font-size: 0.75rem; } -.demo-panel { - max-width: 40rem; - margin: 4rem auto 2rem; - padding: 2rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; -} - -.demo-panel h1 { - margin-top: 0; -} - .btn-primary { background: var(--accent); color: #0f1115; - border: none; + border-style: outset; + border-width: 3px; padding: 0.5rem 1rem; - border-radius: 4px; font-size: 1rem; cursor: pointer; } +.btn-primary:active { + border-style: inset; +} + .btn-primary:hover { filter: brightness(1.1); } @@ -103,11 +94,15 @@ code { color: var(--muted); } +#parser-panel { + display: flex; + flex-direction: column; +} + #parser-input { - width: 100%; + width: 90%; padding: 0.5rem; border: 1px solid var(--border); - border-radius: 4px; background: var(--bg); color: var(--fg); font-size: 1rem; @@ -115,7 +110,12 @@ code { resize: vertical; } +#parser-input:focus-visible { + outline: 0; +} + #parser-form .btn-primary { + width: 10%; margin-top: 0.5rem; }