diff --git a/Cargo.lock b/Cargo.lock index 87b9139..c1caf60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -164,6 +173,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -211,6 +226,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -263,6 +287,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -347,8 +380,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -596,6 +640,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -784,10 +837,13 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" name = "lane-ratatui-premium" version = "1.0.1" dependencies = [ + "base64", "crossterm", + "getrandom 0.2.17", "ratatui", "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.18", "ureq", "url", @@ -1087,7 +1143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1498,8 +1554,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1700,7 +1767,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -2022,7 +2089,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index b23b57c..7fcb124 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,11 @@ description = "Rust/Ratatui GitLab personal kanban TUI" [dependencies] ratatui = "0.30" crossterm = { version = "0.29", features = ["bracketed-paste"] } +base64 = "0.22.1" +getrandom = "0.2.17" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +sha2 = "0.11.0" thiserror = "2.0.18" ureq = { version = "3.3.0", features = ["json"] } url = "2.5.8" diff --git a/README.md b/README.md index 590c665..432152e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Lane Lane is a Rust/Ratatui terminal UI for a personal GitLab-backed kanban board. +GitLab is the source of truth for issues, labels, notes, and board state; Lane +only stores local configuration and secrets. ## Configuration @@ -11,6 +13,20 @@ values persist as `settings.json` in the OS config directory variables (`LANE_GITLAB_URL`, `LANE_GITLAB_TOKEN`, `LANE_PERSONAL_PROJECT_ID`, `LANE_LLM_*`) still work and take precedence over saved settings. +OAuth is supported through environment/config secrets: + +```sh +LANE_GITLAB_URL=https://gitlab.example.com +LANE_PERSONAL_PROJECT_ID=42 +LANE_OAUTH_CLIENT_ID=... +LANE_OAUTH_REDIRECT_URI=http://127.0.0.1:8910/oauth/callback +LANE_OAUTH_SCOPE=api +``` + +`LANE_OAUTH_CLIENT_SECRET` is optional for public/PKCE clients. OAuth access +and refresh tokens are stored under the Lane config directory with owner-only +file permissions on Unix. + Press `?` inside the app for the full keyboard reference (`/` search, `n` new issue, `g` generate stories, `s` sync, `hjkl`/arrows to navigate). @@ -35,3 +51,9 @@ npm run build npm run check:vendor lane ``` + +Generate current TUI screenshots (PNG/SVG/TXT) for visual review: + +```sh +cargo run --example screenshot_capture +``` diff --git a/examples/screenshot_capture.rs b/examples/screenshot_capture.rs new file mode 100644 index 0000000..8f54177 --- /dev/null +++ b/examples/screenshot_capture.rs @@ -0,0 +1,497 @@ +//! Minimal screenshot harness for `App::demo()` renders. +//! +//! Usage: +//! cargo run --example screenshot_capture +//! +//! The command writes plain text snapshots under `target/lane-screenshots`. +//! +//! The snapshots use symbols from Ratatui's `TestBackend` and are designed for +//! quick visual review in a terminal/editor. + +use lane_ratatui_premium::{ + model::{GeneratedStory, GitLabProject, GitLabUser, SourceIdentity}, + render, + ui::{board, chrome, help, modals, settings_ui, stories}, + App, StoryModalFocus, +}; +use ratatui::{backend::TestBackend, layout::*, style::Color, Terminal}; +use std::env; +use std::fs::{self, write}; +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, Instant}; + +const DEFAULT_WIDTH: u16 = 160; +const DEFAULT_HEIGHT: u16 = 47; +const OUTPUT_DIR: &str = "target/lane-screenshots"; +const CELL_W: usize = 8; +const CELL_H: usize = 18; + +#[derive(Copy, Clone)] +enum SnapshotState { + Board, + DetailsPanel, + Settings, + GenerateStories, + Help, + Showcase, +} + +impl SnapshotState { + const fn label(self) -> &'static str { + match self { + SnapshotState::Board => "board", + SnapshotState::DetailsPanel => "details_panel", + SnapshotState::Settings => "settings", + SnapshotState::GenerateStories => "generate_stories", + SnapshotState::Help => "help", + SnapshotState::Showcase => "showcase", + } + } + + fn build_app(self) -> App { + let mut app = App::demo(); + seed_connected_project(&mut app); + match self { + SnapshotState::Board => {} + SnapshotState::DetailsPanel => { + app.active_column = 1; + app.selected[1] = 2; + if let Some(issue) = app.columns[1].get_mut(2) { + issue.assignees = vec!["amit".into()]; + issue.milestone = Some("v1.0".into()); + issue.due_date = Some("2026-07-30".into()); + issue.created_at = Some("2026-05-18T00:00:00Z".into()); + issue.updated_at = Some("2026-05-21T00:00:00Z".into()); + issue.note_count = Some(2); + issue.weight = Some(3); + issue.reference = "team/product/lane#203".into(); + issue.source = Some(SourceIdentity { + project_id: 291, + issue_iid: 201, + }); + } + app.open_details(); + } + SnapshotState::Settings => { + app.settings.gitlab.base_url = "https://gitlab.example.com".into(); + app.settings.gitlab.project_id = "team/product/lane".into(); + app.settings.gitlab.token = "glpat-redacted-token".into(); + app.settings.gitlab.default_labels = "ui, story, bug, enhancement".into(); + app.open_settings(); + } + SnapshotState::GenerateStories => { + app.open_generate_stories(); + app.story_source = + "# Checkout flow\n\nUsers need draft checkout saved safely.".into(); + app.set_generated_stories(vec![ + story( + "Allow user to save draft checkout", + &["checkout", "ux", "story"], + ), + story("Show validation errors in real-time", &["validation", "ux"]), + story("Retry payment on failure", &["payment", "error-handling"]), + story("Display order summary before payment", &["checkout", "ux"]), + ]); + app.story_focus = StoryModalFocus::List; + app.story_status = Some("4 stories generated. Select stories to create.".into()); + } + SnapshotState::Help => app.open_help(), + SnapshotState::Showcase => {} + } + app + } + + const fn required_markers(self) -> &'static [&'static str] { + match self { + SnapshotState::Board => &["Lane", "TODO 09", "#101 Draft user story", "Ready"], + SnapshotState::DetailsPanel => &["Issue #203", "Assignee", "n Notes", "Ready"], + SnapshotState::Settings => &["Settings", "Base URL", "Save (Ctrl+S)"], + SnapshotState::GenerateStories => { + &["Generate Stories", "Generated (4)", "Create Selected"] + } + SnapshotState::Help => &["Keyboard Shortcuts", "Global", "Issue editor"], + SnapshotState::Showcase => &["Issue #203", "Settings", "Generate Stories", "Help"], + } + } +} + +fn seed_connected_project(app: &mut App) { + app.connected_project = Some(GitLabProject { + id: 42, + path_with_namespace: "team/product/lane".into(), + web_url: "https://gitlab.example.com/team/product/lane".into(), + }); + app.connected_user = Some(GitLabUser { + id: 7, + username: "amit".into(), + name: "Amit".into(), + }); + app.last_synced = Some(Instant::now() - Duration::from_secs(120)); +} + +fn story(title: &str, labels: &[&str]) -> GeneratedStory { + GeneratedStory { + title: title.into(), + description: format!("{title} details."), + acceptance_criteria: vec![ + "User can complete the workflow".into(), + "Errors show clear recovery steps".into(), + ], + labels: labels.iter().map(|label| (*label).to_string()).collect(), + priority: None, + size: None, + } +} + +struct RenderedSnapshot { + text: String, + svg: String, + pixel_width: usize, + pixel_height: usize, +} + +fn render_snapshot(width: u16, height: u16, mut app: App) -> RenderedSnapshot { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("creating terminal"); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("rendering demo state"); + + let width = width as usize; + let height = height as usize; + let buffer = terminal.backend().buffer(); + let content = buffer.content(); + + let mut text = String::with_capacity((width + 1) * height); + for row in 0..height { + for col in 0..width { + text.push_str(content[row * width + col].symbol()); + } + if row + 1 < height { + text.push('\n'); + } + } + let svg = render_svg(width, height, content); + RenderedSnapshot { + text, + svg, + pixel_width: width * CELL_W, + pixel_height: height * CELL_H, + } +} + +fn render_showcase_snapshot(width: u16, height: u16) -> RenderedSnapshot { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("creating terminal"); + + terminal + .draw(|frame| { + let root = frame.area(); + let shell = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Percentage(48), + Constraint::Length(3), + Constraint::Percentage(49), + ]) + .split(root); + + let mut top_app = SnapshotState::DetailsPanel.build_app(); + chrome::render_topbar(frame, shell[0], &mut top_app); + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(67), Constraint::Percentage(33)]) + .split(shell[1]); + board::render_board(frame, top[0], &mut top_app); + modals::render_details_panel(frame, top[1], &mut top_app); + top_app.overlay = None; + chrome::render_status(frame, shell[2], &top_app); + + let lower = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(32), + Constraint::Percentage(34), + Constraint::Percentage(34), + ]) + .split(shell[3]); + + let mut settings_app = SnapshotState::Settings.build_app(); + settings_ui::render_settings_modal(frame, lower[0], &mut settings_app); + + let mut stories_app = SnapshotState::GenerateStories.build_app(); + stories::render_generate_stories_modal(frame, lower[1], &mut stories_app); + + let mut help_app = SnapshotState::Help.build_app(); + help::render_help_modal(frame, lower[2], &mut help_app); + }) + .expect("rendering showcase state"); + + snapshot_from_terminal(width, height, &terminal) +} + +fn snapshot_from_terminal( + width: u16, + height: u16, + terminal: &Terminal, +) -> RenderedSnapshot { + let width = width as usize; + let height = height as usize; + let buffer = terminal.backend().buffer(); + let content = buffer.content(); + + let mut text = String::with_capacity((width + 1) * height); + for row in 0..height { + for col in 0..width { + text.push_str(content[row * width + col].symbol()); + } + if row + 1 < height { + text.push('\n'); + } + } + let svg = render_svg(width, height, content); + RenderedSnapshot { + text, + svg, + pixel_width: width * CELL_W, + pixel_height: height * CELL_H, + } +} + +fn render_svg(width: usize, height: usize, content: &[ratatui::buffer::Cell]) -> String { + let px_w = width * CELL_W; + let px_h = height * CELL_H; + let mut svg = format!( + r##" + + +"## + ); + for row in 0..height { + for col in 0..width { + let cell = &content[row * width + col]; + if let Some(bg) = color_hex(cell.bg) { + let x = col * CELL_W; + let y = row * CELL_H; + svg.push_str(&format!( + r#""# + )); + } + } + } + for row in 0..height { + for col in 0..width { + let cell = &content[row * width + col]; + let symbol = cell.symbol(); + if symbol.trim().is_empty() { + continue; + } + let x = col * CELL_W; + let y = row * CELL_H + 2; + let fg = color_hex(cell.fg).unwrap_or("#E5E7EB"); + if let Some(path) = box_symbol_path(symbol, x, row * CELL_H) { + svg.push_str(&format!( + r#""# + )); + continue; + } + svg.push_str(&format!( + r#"{}"#, + escape_xml(symbol) + )); + } + } + svg.push_str("\n"); + svg +} + +fn box_symbol_path(symbol: &str, x: usize, y: usize) -> Option { + let x = x as f32; + let y = y as f32; + let w = CELL_W as f32; + let h = CELL_H as f32; + let cx = x + w / 2.0; + let cy = y + h / 2.0; + let left = x; + let right = x + w; + let top = y; + let bottom = y + h; + + let path = match symbol { + "─" | "═" => format!("M {left} {cy} L {right} {cy}"), + "│" | "║" => format!("M {cx} {top} L {cx} {bottom}"), + "╭" | "┌" => format!("M {cx} {bottom} L {cx} {cy} L {right} {cy}"), + "╮" | "┐" => format!("M {left} {cy} L {cx} {cy} L {cx} {bottom}"), + "╰" | "└" => format!("M {cx} {top} L {cx} {cy} L {right} {cy}"), + "╯" | "┘" => format!("M {left} {cy} L {cx} {cy} L {cx} {top}"), + "├" => format!("M {cx} {top} L {cx} {bottom} M {cx} {cy} L {right} {cy}"), + "┤" => format!("M {cx} {top} L {cx} {bottom} M {left} {cy} L {cx} {cy}"), + "┬" => format!("M {left} {cy} L {right} {cy} M {cx} {cy} L {cx} {bottom}"), + "┴" => format!("M {left} {cy} L {right} {cy} M {cx} {top} L {cx} {cy}"), + "┼" => format!("M {left} {cy} L {right} {cy} M {cx} {top} L {cx} {bottom}"), + _ => return None, + }; + Some(path) +} + +fn color_hex(color: Color) -> Option<&'static str> { + match color { + Color::Reset => None, + Color::Black => Some("#000000"), + Color::Red => Some("#EF4444"), + Color::Green => Some("#22C55E"), + Color::Yellow => Some("#F59E0B"), + Color::Blue => Some("#3B82F6"), + Color::Magenta => Some("#7C5CFF"), + Color::Cyan => Some("#38BDF8"), + Color::Gray => Some("#64748B"), + Color::DarkGray => Some("#263445"), + Color::LightRed => Some("#F87171"), + Color::LightGreen => Some("#4ADE80"), + Color::LightYellow => Some("#FBBF24"), + Color::LightBlue => Some("#60A5FA"), + Color::LightMagenta => Some("#A78BFA"), + Color::LightCyan => Some("#67E8F9"), + Color::White => Some("#E5E7EB"), + Color::Rgb(0x0B, 0x11, 0x17) => Some("#0B1117"), + Color::Rgb(0x11, 0x18, 0x27) => Some("#111827"), + Color::Rgb(0x16, 0x22, 0x31) => Some("#162231"), + Color::Rgb(0x26, 0x34, 0x45) => Some("#263445"), + Color::Rgb(0x7C, 0x5C, 0xFF) => Some("#7C5CFF"), + Color::Rgb(0x3B, 0x82, 0xF6) => Some("#3B82F6"), + Color::Rgb(0x22, 0xC5, 0x5E) => Some("#22C55E"), + Color::Rgb(0xF5, 0x9E, 0x0B) => Some("#F59E0B"), + Color::Rgb(0xEF, 0x44, 0x44) => Some("#EF4444"), + Color::Rgb(0x38, 0xBD, 0xF8) => Some("#38BDF8"), + Color::Rgb(0xE5, 0xE7, 0xEB) => Some("#E5E7EB"), + Color::Rgb(0xA7, 0xB0, 0xC0) => Some("#A7B0C0"), + Color::Rgb(0x64, 0x74, 0x8B) => Some("#64748B"), + Color::Rgb(_, _, _) => Some("#E5E7EB"), + Color::Indexed(_) => Some("#E5E7EB"), + } +} + +fn escape_xml(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn capture_state(state: SnapshotState, width: u16, height: u16) -> (String, RenderedSnapshot) { + let snapshot = if matches!(state, SnapshotState::Showcase) { + render_showcase_snapshot(width, height) + } else { + render_snapshot(width, height, state.build_app()) + }; + (state.label().to_string(), snapshot) +} + +fn parse_args() -> (u16, u16) { + let mut args = env::args().skip(1); + let width = args + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_WIDTH); + let height = args + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_HEIGHT); + (width.max(80), height.max(24)) +} + +fn main() { + let (width, height) = parse_args(); + let output_dir = PathBuf::from(OUTPUT_DIR); + if let Err(error) = fs::create_dir_all(&output_dir) { + eprintln!("Failed to create output directory {output_dir:?}: {error}"); + return; + } + + let states = [ + SnapshotState::Board, + SnapshotState::DetailsPanel, + SnapshotState::Settings, + SnapshotState::GenerateStories, + SnapshotState::Help, + SnapshotState::Showcase, + ]; + + for state in states { + let (label, snapshot) = capture_state(state, width, height); + validate_snapshot(label.as_str(), &snapshot, state.required_markers()); + let text_path = output_dir.join(format!("{label}.txt")); + if let Err(error) = write(&text_path, snapshot.text) { + eprintln!("Failed to write {text_path:?}: {error}"); + return; + } + let svg_path = output_dir.join(format!("{label}.svg")); + if let Err(error) = write(&svg_path, snapshot.svg) { + eprintln!("Failed to write {svg_path:?}: {error}"); + return; + } + let png_path = output_dir.join(format!("{label}.png")); + if render_png_with_chrome( + &svg_path, + &png_path, + snapshot.pixel_width, + snapshot.pixel_height, + ) { + println!("wrote {png_path:?}"); + } + println!("wrote {text_path:?}"); + println!("wrote {svg_path:?}"); + } +} + +fn validate_snapshot(label: &str, snapshot: &RenderedSnapshot, markers: &[&str]) { + for marker in markers { + if !snapshot.text.contains(marker) { + panic!("{label} snapshot missing marker {marker:?}"); + } + } + let visible = snapshot + .text + .chars() + .filter(|ch| !ch.is_whitespace()) + .count(); + assert!(visible > 200, "{label} snapshot looks blank"); +} + +fn render_png_with_chrome( + svg_path: &std::path::Path, + png_path: &std::path::Path, + width: usize, + height: usize, +) -> bool { + let Ok(svg_path) = svg_path.canonicalize() else { + return false; + }; + let Some(chrome) = chrome_binary() else { + return false; + }; + let output = Command::new(chrome) + .arg("--headless=new") + .arg("--disable-gpu") + .arg("--no-sandbox") + .arg(format!("--window-size={width},{height}")) + .arg(format!("--screenshot={}", png_path.display())) + .arg(format!("file://{}", svg_path.display())) + .output(); + + output.is_ok_and(|output| output.status.success()) +} + +fn chrome_binary() -> Option<&'static str> { + ["google-chrome", "chromium", "chromium-browser"] + .into_iter() + .find(|name| { + Command::new("which") + .arg(name) + .output() + .is_ok_and(|output| output.status.success()) + }) +} diff --git a/src/config.rs b/src/config.rs index 75de960..53e6584 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,8 +30,11 @@ impl std::fmt::Debug for Secret { #[derive(Clone, Debug, Eq, PartialEq)] pub struct OAuthConfig { pub client_id: String, + pub client_secret: Option, pub redirect_uri: String, pub scope: String, + pub refresh_token: Option, + pub access_expires_at_epoch: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -43,7 +46,7 @@ pub enum AuthMode { #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitLabConfig { pub instance_url: Url, - pub token: Secret, + pub token: Option, pub personal_project_id: u64, pub auth_mode: AuthMode, pub oauth: Option, @@ -75,8 +78,11 @@ pub struct EnvConfig { pub llm_enabled: Option, pub llm_temperature: Option, pub oauth_client_id: Option, + pub oauth_client_secret: Option, pub oauth_redirect_uri: Option, pub oauth_scope: Option, + pub oauth_refresh_token: Option, + pub oauth_access_expires_at: Option, } const DEFAULT_LLM_TEMPERATURE: f32 = 0.4; @@ -97,8 +103,11 @@ impl EnvConfig { llm_enabled: env::var("LANE_LLM_ENABLED").ok(), llm_temperature: env::var("LANE_LLM_TEMPERATURE").ok(), oauth_client_id: env::var("LANE_OAUTH_CLIENT_ID").ok(), + oauth_client_secret: env::var("LANE_OAUTH_CLIENT_SECRET").ok(), oauth_redirect_uri: env::var("LANE_OAUTH_REDIRECT_URI").ok(), oauth_scope: env::var("LANE_OAUTH_SCOPE").ok(), + oauth_refresh_token: env::var("LANE_OAUTH_REFRESH_TOKEN").ok(), + oauth_access_expires_at: env::var("LANE_OAUTH_ACCESS_EXPIRES_AT").ok(), } } } @@ -114,9 +123,6 @@ impl AppConfig { env.gitlab_instance_url .ok_or(ConfigError::Missing("LANE_GITLAB_URL"))?, )?; - let token = env - .gitlab_token - .ok_or(ConfigError::Missing("LANE_GITLAB_TOKEN"))?; let personal_project_id = env .personal_project_id .ok_or(ConfigError::Missing("LANE_PERSONAL_PROJECT_ID"))? @@ -140,10 +146,18 @@ impl AppConfig { .unwrap_or_else(|| DEFAULT_LLM_BASE_URL.to_string()), )?; - let (auth_mode, oauth) = if env.oauth_client_id.is_some() + let oauth_requested = env.oauth_client_id.is_some() + || env.oauth_client_secret.is_some() || env.oauth_redirect_uri.is_some() || env.oauth_scope.is_some() - { + || env.oauth_refresh_token.is_some() + || env.oauth_access_expires_at.is_some(); + let token = env.gitlab_token.map(Secret::new); + if token.is_none() && !oauth_requested { + return Err(ConfigError::Missing("LANE_GITLAB_TOKEN")); + } + + let (auth_mode, oauth) = if oauth_requested { let client_id = env .oauth_client_id .clone() @@ -156,12 +170,24 @@ impl AppConfig { .oauth_scope .clone() .unwrap_or_else(|| DEFAULT_OAUTH_SCOPE.to_string()); + let access_expires_at_epoch = env + .oauth_access_expires_at + .map(|value| { + value.parse::().map_err(|e| ConfigError::Invalid { + name: "LANE_OAUTH_ACCESS_EXPIRES_AT", + reason: e.to_string(), + }) + }) + .transpose()?; ( AuthMode::OAuth, Some(OAuthConfig { client_id, + client_secret: env.oauth_client_secret.map(Secret::new), redirect_uri, scope, + refresh_token: env.oauth_refresh_token.map(Secret::new), + access_expires_at_epoch, }), ) } else { @@ -171,7 +197,7 @@ impl AppConfig { Ok(Self { gitlab: GitLabConfig { instance_url, - token: Secret::new(token), + token, personal_project_id, auth_mode, oauth, @@ -245,8 +271,11 @@ mod tests { llm_enabled: None, llm_temperature: None, oauth_client_id: None, + oauth_client_secret: None, oauth_redirect_uri: None, oauth_scope: None, + oauth_refresh_token: None, + oauth_access_expires_at: None, }; let cfg = AppConfig::from_env_values(env).expect("config"); @@ -256,7 +285,10 @@ mod tests { "https://gitlab.example.com/" ); assert_eq!(cfg.gitlab.personal_project_id, 42); - assert_eq!(cfg.gitlab.token.expose_for_test(), "glpat-secret"); + assert_eq!( + cfg.gitlab.token.as_ref().unwrap().expose_for_test(), + "glpat-secret" + ); assert_eq!(cfg.gitlab.auth_mode, AuthMode::Pat); assert_eq!(cfg.llm.base_url.as_str(), "https://ollama.example.com/v1"); assert_eq!(cfg.llm.model, "gpt-oss:120b"); @@ -276,8 +308,11 @@ mod tests { llm_enabled: None, llm_temperature: None, oauth_client_id: None, + oauth_client_secret: None, oauth_redirect_uri: None, oauth_scope: None, + oauth_refresh_token: None, + oauth_access_expires_at: None, }; let err = AppConfig::from_env_values(env).expect_err("missing token"); @@ -297,8 +332,11 @@ mod tests { llm_enabled: Some("true".into()), llm_temperature: Some("0.4".into()), oauth_client_id: Some("client".into()), + oauth_client_secret: Some("secret".into()), oauth_redirect_uri: Some("http://localhost/callback".into()), oauth_scope: None, + oauth_refresh_token: Some("refresh".into()), + oauth_access_expires_at: Some("1234".into()), }; let cfg = AppConfig::from_env_values(env).unwrap(); @@ -307,6 +345,49 @@ mod tests { assert_eq!(cfg.llm.temperature, Some(0.4)); assert_eq!(cfg.gitlab.auth_mode, AuthMode::OAuth); assert_eq!(cfg.gitlab.oauth.as_ref().unwrap().client_id, "client"); + assert_eq!( + cfg.gitlab + .oauth + .as_ref() + .unwrap() + .client_secret + .as_ref() + .unwrap() + .expose_for_test(), + "secret" + ); assert_eq!(cfg.gitlab.oauth.as_ref().unwrap().scope, "api"); + assert_eq!( + cfg.gitlab + .oauth + .as_ref() + .unwrap() + .refresh_token + .as_ref() + .unwrap() + .expose_for_test(), + "refresh" + ); + assert_eq!( + cfg.gitlab.oauth.as_ref().unwrap().access_expires_at_epoch, + Some(1234) + ); + } + + #[test] + fn oauth_config_does_not_require_gitlab_token() { + let env = EnvConfig { + gitlab_instance_url: Some("https://gitlab.example.com".into()), + gitlab_token: None, + personal_project_id: Some("42".into()), + oauth_client_id: Some("client".into()), + oauth_redirect_uri: Some("http://127.0.0.1:8910/oauth/callback".into()), + ..EnvConfig::default() + }; + + let cfg = AppConfig::from_env_values(env).unwrap(); + + assert_eq!(cfg.gitlab.auth_mode, AuthMode::OAuth); + assert!(cfg.gitlab.token.is_none()); } } diff --git a/src/jobs.rs b/src/jobs.rs index 0174ee7..31cb415 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -254,6 +254,61 @@ pub enum JobResult { CreatedStories(PartialCreateResult), } +pub fn failed_job_result(command: JobCommand, error: String) -> JobResult { + match command { + JobCommand::SyncAll => JobResult::Synced(Err(error)), + JobCommand::Connect => JobResult::Connected(Err(error)), + JobCommand::BootstrapLabels => JobResult::BootstrappedLabels(Err(error)), + JobCommand::CreateIssue { .. } => JobResult::IssueCreated(Err(error)), + JobCommand::UpdateIssue { .. } + | JobCommand::MoveIssue { .. } + | JobCommand::ToggleFlag { .. } => JobResult::IssueUpdated(Err(error)), + JobCommand::LoadNotes { .. } => JobResult::NotesLoaded(Err(error)), + JobCommand::CreateNote { + iid, + body, + request_id, + } => JobResult::NoteCreated(Err(NoteJobError { + iid, + request_id, + body, + error, + })), + JobCommand::PullSourceSnapshot { .. } => JobResult::SourceSnapshotPulled(Err(error)), + JobCommand::DeleteIssue { .. } => JobResult::IssueDeleted(Err(error)), + JobCommand::ImproveDescription { generation_id, .. } => JobResult::ImprovedDescription { + generation_id, + result: Err(error), + }, + JobCommand::GenerateCriteria { generation_id, .. } => JobResult::GeneratedCriteria { + generation_id, + result: Err(error), + }, + JobCommand::SuggestLabels { generation_id, .. } => JobResult::SuggestedLabels { + generation_id, + result: Err(error), + }, + JobCommand::GenerateStories { generation_id, .. } => JobResult::GeneratedStories { + generation_id, + result: Err(error), + }, + JobCommand::CreateSelectedStories { story_drafts, .. } => { + let failed = story_drafts + .into_iter() + .filter(|draft| draft.selected) + .map(|draft| StoryCreateFailure { + title: draft.story.title, + error: error.clone(), + }) + .collect(); + JobResult::CreatedStories(PartialCreateResult { + created: Vec::new(), + failed, + }) + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct PartialCreateResult { pub created: Vec, @@ -1169,6 +1224,29 @@ mod tests { use std::cell::RefCell; use std::collections::HashSet; + #[test] + fn failed_job_result_maps_auth_failure_to_original_job_kind() { + assert_eq!( + failed_job_result(JobCommand::Connect, "auth failed".into()), + JobResult::Connected(Err("auth failed".into())) + ); + + assert_eq!( + failed_job_result( + JobCommand::GenerateStories { + source: "input".into(), + available_labels: vec![], + generation_id: 7, + }, + "auth failed".into(), + ), + JobResult::GeneratedStories { + generation_id: 7, + result: Err("auth failed".into()) + } + ); + } + #[test] fn job_result_applies_generated_stories_to_app() { let mut app = crate::App::demo(); @@ -2902,6 +2980,9 @@ mod tests { references: GitLabReferences { full: format!("me/lane#{iid}"), }, + assignees: Vec::new(), + milestone: None, + created_at: None, user_notes_count: None, updated_at: None, }) @@ -3025,6 +3106,9 @@ mod tests { references: GitLabReferences { full: format!("me/lane#{iid}"), }, + assignees: Vec::new(), + milestone: None, + created_at: None, user_notes_count: None, updated_at: None, } @@ -3063,6 +3147,9 @@ mod tests { labels: vec!["backend".into()], due_date: None, weight: None, + assignees: Vec::new(), + milestone: None, + created_at: None, web_url: format!("https://gitlab.example.com/me/lane/-/issues/{iid}"), reference: format!("me/lane#{iid}"), flags: vec![], diff --git a/src/lib.rs b/src/lib.rs index 1a3573e..a7c1618 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,10 +2,9 @@ use ratatui::layout::Rect; use std::collections::{HashMap, HashSet}; use crate::labels::pick_user_labels; -use crate::model::{Flag, GitLabLabel, GitLabNote, GitLabProject, GitLabUser, SourceIdentity}; +use crate::model::{GitLabLabel, GitLabNote, GitLabProject, GitLabUser, SourceIdentity}; use crate::sanitize::sanitize_for_gitlab; use crate::settings::{Settings, SettingsForm}; -use crate::storage::FileStore; use crate::sync::SourceIssueKey; pub mod config; @@ -14,6 +13,7 @@ pub mod jobs; pub mod labels; pub mod llm; pub mod model; +pub mod oauth; pub mod sanitize; pub mod settings; pub mod storage; @@ -56,6 +56,13 @@ pub struct Issue { pub labels: Vec, pub blocked: bool, pub due_date: Option, + pub assignees: Vec, + pub milestone: Option, + pub created_at: Option, + pub updated_at: Option, + pub note_count: Option, + pub weight: Option, + pub reference: String, pub source: Option, } @@ -124,9 +131,13 @@ pub enum ClickTarget { SelectAllStories, Settings, SettingsSection { index: usize }, + SettingsField { index: usize }, Help, EditIssue, + Notes, MoveIssueRight, + StoryTab { tab: StoryTab }, + StoryDraft { index: usize }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -202,6 +213,7 @@ pub struct App { pub palette_query: String, pub palette_selected: usize, pub drag: Option, + pub hover: Option, pub hitboxes: Vec, pub story_source: String, pub generated_stories: Vec, @@ -233,7 +245,6 @@ pub struct App { pub note_submit_pending: Option<(u64, String)>, pub open_assigned_set: HashSet, pub has_synced: bool, - pub store: Option, pub pending_job: Option, pub story_create_in_flight: bool, pub editor_submit_in_flight: bool, @@ -399,6 +410,7 @@ impl App { palette_query: String::new(), palette_selected: 0, drag: None, + hover: None, hitboxes: Vec::new(), story_source: String::new(), generated_stories: Vec::new(), @@ -430,7 +442,6 @@ impl App { note_submit_pending: None, open_assigned_set: HashSet::new(), has_synced: false, - store: None, pending_job: None, story_create_in_flight: false, editor_submit_in_flight: false, @@ -442,10 +453,6 @@ impl App { } } - pub fn set_store(&mut self, store: FileStore) { - self.store = Some(store); - } - pub fn set_status(&mut self, kind: StatusKind, message: impl Into) { self.status_kind = kind; self.story_status = Some(message.into()); @@ -1118,7 +1125,6 @@ impl App { self.selected = vec![0, 0, 0]; self.scroll = vec![0, 0, 0]; self.append_issue_records(records); - self.apply_saved_order(); if let Some(iid) = selected_iid { for column in 0..self.columns.len() { if let Some(index) = self.columns[column] @@ -1589,6 +1595,13 @@ impl App { }, blocked: false, due_date: None, + assignees: Vec::new(), + milestone: None, + created_at: None, + updated_at: None, + note_count: None, + weight: None, + reference: format!("#{iid}"), source: None, }); self.active_column = 0; @@ -1656,7 +1669,6 @@ impl App { pub fn toggle_blocked(&mut self) { if self.connected_project.is_some() { if let Some(issue) = self.selected_issue_opt() { - self.persist_flag_reason(issue.iid, Flag::Blocked, !issue.blocked); self.pending_job = Some(crate::jobs::JobCommand::ToggleFlag { iid: issue.iid, flag: crate::model::Flag::Blocked, @@ -1667,17 +1679,12 @@ impl App { } else { let column = self.active_column; let index = self.selected[column]; - let mut flag_change = None; if let Some(issue) = self .columns .get_mut(column) .and_then(|issues| issues.get_mut(index)) { issue.blocked = !issue.blocked; - flag_change = Some((issue.iid, issue.blocked)); - } - if let Some((iid, on)) = flag_change { - self.persist_flag_reason(iid, Flag::Blocked, on); } } } @@ -1721,6 +1728,7 @@ impl App { ClickTarget::Settings => self.open_settings(), ClickTarget::Help => self.open_help(), ClickTarget::EditIssue => self.open_edit_issue(), + ClickTarget::Notes => self.open_notes(), ClickTarget::MoveIssueRight => self.move_selected_issue_right(), ClickTarget::SelectAllStories => self.toggle_select_all_stories(), ClickTarget::SettingsSection { index } => { @@ -1729,6 +1737,34 @@ impl App { form.focused = 0; } } + ClickTarget::SettingsField { index } => { + if let Some(form) = self.settings_form.as_mut() { + form.focused = index.min(form.field_count().saturating_sub(1)); + if form.field_is_toggle(form.focused) { + form.toggle(); + } + } + } + ClickTarget::StoryTab { tab } => { + if self.overlay == Some(Overlay::GenerateStories) { + self.story_preview = tab == StoryTab::Preview; + self.story_focus = match tab { + StoryTab::Edit | StoryTab::Preview => StoryModalFocus::Source, + StoryTab::Generated => StoryModalFocus::List, + }; + } + } + ClickTarget::StoryDraft { index } => { + if self.overlay == Some(Overlay::GenerateStories) + && index < self.generated_stories.len() + { + self.story_preview = false; + self.story_focus = StoryModalFocus::List; + self.story_selected = index; + self.generated_stories[index].selected = + !self.generated_stories[index].selected; + } + } } } @@ -1766,6 +1802,7 @@ impl App { pub fn mouse_drag_at(&mut self, x: u16, y: u16) { let over = self.drop_target_at(x, y); + self.hover = self.hit_target_at(x, y); if let Some(drag) = self.drag.as_mut() { drag.current_x = x; drag.current_y = y; @@ -1774,7 +1811,12 @@ impl App { } } + pub fn mouse_move_at(&mut self, x: u16, y: u16) { + self.hover = self.hit_target_at(x, y); + } + pub fn mouse_up_at(&mut self, x: u16, y: u16) { + self.hover = self.hit_target_at(x, y); if self.overlay.is_some() { self.click_at(x, y); return; @@ -1809,6 +1851,10 @@ impl App { self.cancel_drag(); } + pub fn is_hovered(&self, target: &ClickTarget) -> bool { + self.hover.as_ref().is_some_and(|hover| hover == target) + } + pub fn wheel_at(&mut self, x: u16, y: u16, delta: i16) { match self.overlay { Some(Overlay::Details | Overlay::EditIssue | Overlay::NewIssue) => { @@ -2076,55 +2122,6 @@ impl App { self.active_column = target_column; self.selected[target_column] = target_index; self.ensure_selected_visible(target_column, 4); - self.persist_sidecar_order(target_column); - if drag.source_column != target_column { - self.persist_sidecar_order(drag.source_column); - } - } - - fn apply_saved_order(&mut self) { - for column in 0..self.columns.len() { - self.apply_saved_order_for_column(column); - } - } - - fn apply_saved_order_for_column(&mut self, column: usize) { - let Some(store) = self.store.as_ref() else { - return; - }; - let key = sidecar_column_key(column); - let Ok(Some(order)) = store.load_sidecar_order(key) else { - return; - }; - let issues = &mut self.columns[column]; - issues.sort_by_key(|issue| { - order - .iter() - .position(|iid| *iid == issue.iid) - .unwrap_or(order.len()) - }); - } - - fn persist_sidecar_order(&self, column: usize) { - let Some(store) = self.store.as_ref() else { - return; - }; - let Some(issues) = self.columns.get(column) else { - return; - }; - let order: Vec = issues.iter().map(|issue| issue.iid).collect(); - let _ = store.save_sidecar_order(sidecar_column_key(column), &order); - } - - fn persist_flag_reason(&self, iid: u64, flag: Flag, on: bool) { - let Some(store) = self.store.as_ref() else { - return; - }; - let _ = if on { - store.save_flag_reason(iid, flag, "") - } else { - store.clear_flag_reason(iid, flag) - }; } pub(crate) fn next_iid(&self) -> u64 { @@ -2201,6 +2198,13 @@ fn issue(iid: u64, title: &str, state: LaneState, labels: &[&'static str], block labels: labels.iter().map(|label| (*label).to_string()).collect(), blocked, due_date: None, + assignees: Vec::new(), + milestone: None, + created_at: None, + updated_at: None, + note_count: None, + weight: None, + reference: format!("#{iid}"), source: None, } } @@ -2214,6 +2218,13 @@ fn issue_from_record(record: &crate::model::IssueRecord) -> Issue { labels: record.labels.clone(), blocked: record.flags.contains(&crate::model::Flag::Blocked), due_date: record.due_date.clone(), + assignees: record.assignees.clone(), + milestone: record.milestone.clone(), + created_at: record.created_at.clone(), + updated_at: record.updated_at.clone(), + note_count: record.note_count, + weight: record.weight, + reference: record.reference.clone(), source: record.source, } } @@ -2256,19 +2267,10 @@ pub(crate) fn state_for(index: usize) -> LaneState { } } -fn sidecar_column_key(column: usize) -> &'static str { - match column { - 0 => "todo", - 1 => "doing", - _ => "done", - } -} - #[cfg(test)] mod tests { use super::*; use crate::model::BoardState; - use crate::storage::FileStore; fn issue_record(iid: u64, title: &str, state: BoardState) -> crate::model::IssueRecord { crate::model::IssueRecord { @@ -2281,6 +2283,9 @@ mod tests { labels: Vec::new(), due_date: None, weight: None, + assignees: Vec::new(), + milestone: None, + created_at: None, web_url: format!("https://gitlab.example.com/me/lane/-/issues/{iid}"), reference: format!("me/lane#{iid}"), flags: Vec::new(), @@ -2326,45 +2331,8 @@ mod tests { } #[test] - fn connected_reorder_persists_sidecar_order() { - let root = unique_temp_root("order"); - let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); + fn replace_issue_records_uses_gitlab_order() { let mut app = App::demo(); - app.connected_project = Some(GitLabProject { - id: 42, - path_with_namespace: "me/lane".into(), - web_url: "https://gitlab.example.com/me/lane".into(), - }); - app.set_store(store.clone()); - - app.drag = Some(DragState { - source_column: 0, - source_index: 0, - current_x: 0, - current_y: 0, - over: Some(DropTarget::Issue { - column: 0, - index: 2, - }), - moved: true, - }); - app.mouse_up_at(0, 0); - - assert_eq!( - store.load_sidecar_order("todo").unwrap(), - Some(vec![102, 101, 103, 104, 105, 106, 107, 108, 109]) - ); - - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn replace_issue_records_applies_saved_sidecar_order() { - let root = unique_temp_root("apply-order"); - let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); - store.save_sidecar_order("todo", &[103, 101, 102]).unwrap(); - let mut app = App::demo(); - app.set_store(store); app.replace_issue_records(&[ issue_record(101, "One", BoardState::Todo), @@ -2377,10 +2345,8 @@ mod tests { .iter() .map(|issue| issue.iid) .collect::>(), - vec![103, 101, 102] + vec![101, 102, 103] ); - - let _ = std::fs::remove_dir_all(root); } #[test] @@ -2402,32 +2368,6 @@ mod tests { assert!(app.ai_job_in_flight); } - #[test] - fn toggle_blocked_persists_empty_reason_and_clears_when_removed() { - let root = unique_temp_root("flag"); - let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); - let mut app = App::demo(); - app.set_store(store.clone()); - - app.toggle_blocked(); - assert_eq!( - store - .load_flag_reason(101, crate::model::Flag::Blocked) - .unwrap(), - Some(String::new()) - ); - - app.toggle_blocked(); - assert_eq!( - store - .load_flag_reason(101, crate::model::Flag::Blocked) - .unwrap(), - None - ); - - let _ = std::fs::remove_dir_all(root); - } - #[test] fn palette_lists_sync_and_generate_stories_actions() { let app = App::demo(); @@ -2700,6 +2640,30 @@ mod tests { assert_eq!(form.focused, 0); } + #[test] + fn settings_field_click_focuses_and_toggles() { + let mut app = App::demo(); + app.open_settings(); + app.settings_form.as_mut().expect("form").section = 1; + + app.click(ClickTarget::SettingsField { index: 4 }); + + let form = app.settings_form.as_ref().expect("form"); + assert_eq!(form.focused, 4); + assert_eq!(form.field_value(4), "on"); + } + + #[test] + fn details_notes_click_opens_notes() { + let mut app = App::demo(); + app.open_details(); + + app.click(ClickTarget::Notes); + + assert_eq!(app.overlay, Some(Overlay::Notes)); + assert_eq!(app.notes_issue_iid, Some(101)); + } + #[test] fn story_preview_toggle_blocks_source_edits() { let mut app = App::demo(); @@ -2737,6 +2701,29 @@ mod tests { assert_eq!(app.story_tab(), StoryTab::Generated); } + #[test] + fn story_mouse_targets_switch_tabs_and_toggle_draft() { + let mut app = App::demo(); + app.open_generate_stories(); + app.set_generated_stories(vec![crate::model::GeneratedStory { + title: "Draft".into(), + description: "Body".into(), + acceptance_criteria: vec![], + labels: vec![], + priority: None, + size: None, + }]); + + app.click(ClickTarget::StoryTab { + tab: StoryTab::Preview, + }); + assert_eq!(app.story_tab(), StoryTab::Preview); + + app.click(ClickTarget::StoryDraft { index: 0 }); + assert_eq!(app.story_tab(), StoryTab::Generated); + assert!(!app.generated_stories[0].selected); + } + #[test] fn help_overlay_opens_and_scrolls() { let mut app = App::demo(); @@ -3297,6 +3284,9 @@ mod tests { labels: vec!["backend".into()], due_date: None, weight: None, + assignees: Vec::new(), + milestone: None, + created_at: None, web_url: "https://gitlab.example.com/me/lane/-/issues/77".into(), reference: "me/lane#77".into(), flags: vec![], @@ -3338,6 +3328,9 @@ mod tests { labels: vec!["backend".into()], due_date: None, weight: None, + assignees: Vec::new(), + milestone: None, + created_at: None, web_url: "https://gitlab.example.com/me/lane/-/issues/77".into(), reference: "me/lane#77".into(), flags: vec![], diff --git a/src/main.rs b/src/main.rs index a5c2d8b..7b61fee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,11 +14,12 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use lane_ratatui_premium::{ - config::{AppConfig, ConfigError, EnvConfig}, + config::{AppConfig, AuthMode, ConfigError, EnvConfig}, gitlab::{GitLabClient, GitLabClientConfig}, jobs::{self, JobCommand, JobContext, JobResult}, labels::pick_user_labels, llm::{LlmClient, LlmClientConfig}, + oauth::{self, OAuthClientConfig, OAuthTokenSet}, render, storage::FileStore, App, Overlay, StatusKind, @@ -122,14 +123,6 @@ fn run(terminal: &mut Terminal>, app: &mut App) -> } poll_job_result(&mut job_rx, app); - if app.connected_project.is_some() - && app.settings.gitlab.sync_on_startup - && !app.has_synced - && app.pending_job.is_none() - && job_rx.is_none() - { - app.request_sync_all(); - } maybe_start_job(&config, &mut job_rx, app); app.tick_loading(); @@ -152,10 +145,6 @@ fn prepare_startup(config: &Result, app: &mut App) { match config { Ok(config) => { app.ai_enabled = config.llm.enabled; - app.set_store(FileStore::for_current_user( - config.gitlab.instance_url.as_str(), - config.gitlab.personal_project_id, - )); app.request_connect(); } Err(err) => { @@ -185,10 +174,30 @@ fn load_config() -> Result { let store = FileStore::for_current_user(&instance_url, personal_project_id); if env.gitlab_token.is_none() { - if let Ok(Some(token)) = store.load_secret("gitlab_token") { + let key = if env_uses_oauth(&env) { + "oauth_access_token" + } else { + "gitlab_token" + }; + if let Ok(Some(token)) = store.load_secret(key) { env.gitlab_token = Some(token); } } + if env.oauth_client_secret.is_none() { + if let Ok(Some(secret)) = store.load_secret("oauth_client_secret") { + env.oauth_client_secret = Some(secret); + } + } + if env.oauth_refresh_token.is_none() { + if let Ok(Some(refresh)) = store.load_secret("oauth_refresh_token") { + env.oauth_refresh_token = Some(refresh); + } + } + if env.oauth_access_expires_at.is_none() { + if let Ok(Some(expires_at)) = store.load_secret("oauth_access_expires_at") { + env.oauth_access_expires_at = Some(expires_at); + } + } if env.llm_api_key.is_none() { if let Ok(Some(api_key)) = store.load_secret("llm_api_key") { env.llm_api_key = Some(api_key); @@ -198,6 +207,15 @@ fn load_config() -> Result { AppConfig::from_env_values(env) } +fn env_uses_oauth(env: &EnvConfig) -> bool { + env.oauth_client_id.is_some() + || env.oauth_client_secret.is_some() + || env.oauth_redirect_uri.is_some() + || env.oauth_scope.is_some() + || env.oauth_refresh_token.is_some() + || env.oauth_access_expires_at.is_some() +} + fn poll_job_result(job_rx: &mut Option>, app: &mut App) { let Some(rx) = job_rx.as_ref() else { return; @@ -258,9 +276,17 @@ fn maybe_start_job( let (tx, rx) = mpsc::channel(); *job_rx = Some(rx); thread::spawn(move || { + let command = request.command; + let token = match resolve_gitlab_token(&config) { + Ok(token) => token, + Err(error) => { + let _ = tx.send(jobs::failed_job_result(command, error)); + return; + } + }; let gitlab = GitLabClient::new(GitLabClientConfig { instance_url: config.gitlab.instance_url.clone(), - token: config.gitlab.token.as_str().to_string(), + token, timeout_ms: 30_000, max_pages: 50, max_items: 5000, @@ -282,12 +308,109 @@ fn maybe_start_job( llm: &llm, personal_project_id: config.gitlab.personal_project_id, }, - request.command, + command, ); let _ = tx.send(result); }); } +fn resolve_gitlab_token(config: &AppConfig) -> Result { + if config.gitlab.auth_mode == AuthMode::Pat { + return config + .gitlab + .token + .as_ref() + .map(|token| token.as_str().to_string()) + .ok_or_else(|| "missing LANE_GITLAB_TOKEN".to_string()); + } + + let oauth_config = oauth_client_config(config)?; + let store = FileStore::for_current_user( + config.gitlab.instance_url.as_str(), + config.gitlab.personal_project_id, + ); + let now = unix_now(); + if let Some(token) = config.gitlab.token.as_ref() { + let expires_at = config + .gitlab + .oauth + .as_ref() + .and_then(|oauth| oauth.access_expires_at_epoch); + let token_set = OAuthTokenSet { + access_token: token.as_str().to_string(), + refresh_token: config + .gitlab + .oauth + .as_ref() + .and_then(|oauth| oauth.refresh_token.as_ref()) + .map(|secret| secret.as_str().to_string()), + token_type: "Bearer".into(), + expires_at_epoch: expires_at, + }; + if !token_set.expires_within(now, 60) { + return Ok(token_set.access_token); + } + } + + if let Some(refresh_token) = config + .gitlab + .oauth + .as_ref() + .and_then(|oauth| oauth.refresh_token.as_ref()) + { + if let Ok(token_set) = oauth::refresh_access_token(&oauth_config, refresh_token.as_str()) { + save_oauth_tokens(&store, &token_set).map_err(|error| error.to_string())?; + return Ok(token_set.access_token); + } + } + + let token_set = oauth::run_loopback_authorization(&oauth_config) + .map_err(|error| format!("OAuth login failed: {error}"))?; + save_oauth_tokens(&store, &token_set).map_err(|error| error.to_string())?; + Ok(token_set.access_token) +} + +fn oauth_client_config(config: &AppConfig) -> Result { + let oauth = config + .gitlab + .oauth + .as_ref() + .ok_or_else(|| "missing OAuth configuration".to_string())?; + Ok(OAuthClientConfig { + instance_url: config.gitlab.instance_url.clone(), + client_id: oauth.client_id.clone(), + client_secret: oauth + .client_secret + .as_ref() + .map(|secret| secret.as_str().to_string()), + redirect_uri: url::Url::parse(&oauth.redirect_uri) + .map_err(|error| format!("invalid LANE_OAUTH_REDIRECT_URI: {error}"))?, + scope: oauth.scope.clone(), + timeout_ms: 120_000, + }) +} + +fn save_oauth_tokens( + store: &FileStore, + token_set: &OAuthTokenSet, +) -> Result<(), lane_ratatui_premium::storage::StorageError> { + store.save_secret("oauth_access_token", &token_set.access_token)?; + if let Some(refresh_token) = &token_set.refresh_token { + store.save_secret("oauth_refresh_token", refresh_token)?; + } + if let Some(expires_at) = token_set.expires_at_epoch { + store.save_secret("oauth_access_expires_at", &expires_at.to_string())?; + } + Ok(()) +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + fn busy_label(command: &JobCommand) -> &'static str { match command { JobCommand::Connect => "Connecting", @@ -334,6 +457,7 @@ fn handle_event(event: Event, app: &mut App) { MouseEventKind::Down(MouseButton::Left) => app.mouse_down_at(mouse.column, mouse.row), MouseEventKind::Drag(MouseButton::Left) => app.mouse_drag_at(mouse.column, mouse.row), MouseEventKind::Up(MouseButton::Left) => app.mouse_up_at(mouse.column, mouse.row), + MouseEventKind::Moved => app.mouse_move_at(mouse.column, mouse.row), MouseEventKind::Down(MouseButton::Right) | MouseEventKind::Up(MouseButton::Right) => { app.right_click_at(mouse.column, mouse.row) } @@ -650,6 +774,8 @@ fn handle_confirm_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { mod tests { use super::*; use lane_ratatui_premium::jobs::JobCommand; + use lane_ratatui_premium::ClickTarget; + use ratatui::layout::Rect; #[test] fn board_g_shortcut_opens_generate_stories() { @@ -680,6 +806,24 @@ mod tests { assert_eq!(app.story_status.as_deref(), Some("Connecting to GitLab")); } + #[test] + fn mouse_moved_event_updates_hover_target() { + let mut app = App::demo(); + app.add_hitbox(Rect::new(4, 5, 10, 2), ClickTarget::Help); + + handle_event( + Event::Mouse(crossterm::event::MouseEvent { + kind: MouseEventKind::Moved, + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }), + &mut app, + ); + + assert_eq!(app.hover, Some(ClickTarget::Help)); + } + #[test] fn startup_requests_connect_when_config_is_valid() { let config = AppConfig::from_env_values(EnvConfig { @@ -692,8 +836,11 @@ mod tests { llm_enabled: None, llm_temperature: None, oauth_client_id: None, + oauth_client_secret: None, oauth_redirect_uri: None, oauth_scope: None, + oauth_refresh_token: None, + oauth_access_expires_at: None, }) .expect("config"); let mut app = App::demo(); @@ -702,7 +849,6 @@ mod tests { assert_eq!(app.pending_job, Some(JobCommand::Connect)); assert_eq!(app.story_status.as_deref(), Some("Connecting to GitLab")); - assert!(app.store.is_some()); } #[test] diff --git a/src/model.rs b/src/model.rs index ef2c7be..5290488 100644 --- a/src/model.rs +++ b/src/model.rs @@ -46,6 +46,12 @@ pub struct GitLabIssue { pub web_url: String, pub references: GitLabReferences, #[serde(default)] + pub assignees: Vec, + #[serde(default)] + pub milestone: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] pub user_notes_count: Option, #[serde(default)] pub updated_at: Option, @@ -65,6 +71,11 @@ pub struct GitLabProject { pub web_url: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct GitLabMilestone { + pub title: String, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] pub struct GitLabNote { pub id: u64, @@ -83,6 +94,9 @@ pub struct IssueRecord { pub labels: Vec, pub due_date: Option, pub weight: Option, + pub assignees: Vec, + pub milestone: Option, + pub created_at: Option, pub web_url: String, pub reference: String, pub flags: Vec, @@ -164,6 +178,13 @@ impl IssueRecord { labels, due_date: issue.due_date, weight: issue.weight, + assignees: issue + .assignees + .into_iter() + .map(|user| user.username) + .collect(), + milestone: issue.milestone.map(|milestone| milestone.title), + created_at: issue.created_at, web_url: issue.web_url, reference: issue.references.full, flags, @@ -195,6 +216,9 @@ mod tests { references: GitLabReferences { full: "me/lane#101".into(), }, + assignees: Vec::new(), + milestone: None, + created_at: None, user_notes_count: None, updated_at: None, }; @@ -230,6 +254,15 @@ mod tests { references: GitLabReferences { full: "me/lane#101".into(), }, + assignees: vec![GitLabUser { + id: 7, + username: "amit".into(), + name: "Amit".into(), + }], + milestone: Some(GitLabMilestone { + title: "v1.0".into(), + }), + created_at: Some("2026-07-01T00:00:00Z".into()), user_notes_count: Some(2), updated_at: Some("2026-07-02T00:00:00Z".into()), }; @@ -248,5 +281,8 @@ mod tests { ); assert_eq!(issue.labels, vec!["ux"]); assert_eq!(issue.note_count, Some(2)); + assert_eq!(issue.assignees, vec!["amit"]); + assert_eq!(issue.milestone.as_deref(), Some("v1.0")); + assert_eq!(issue.created_at.as_deref(), Some("2026-07-01T00:00:00Z")); } } diff --git a/src/oauth.rs b/src/oauth.rs new file mode 100644 index 0000000..9c7ee67 --- /dev/null +++ b/src/oauth.rs @@ -0,0 +1,451 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use url::Url; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OAuthClientConfig { + pub instance_url: Url, + pub client_id: String, + pub client_secret: Option, + pub redirect_uri: Url, + pub scope: String, + pub timeout_ms: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PkceChallenge { + pub verifier: String, + pub challenge: String, + pub state: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OAuthTokenSet { + pub access_token: String, + pub refresh_token: Option, + pub token_type: String, + pub expires_at_epoch: Option, +} + +impl OAuthTokenSet { + pub fn expires_within(&self, now_epoch: u64, skew_seconds: u64) -> bool { + self.expires_at_epoch + .is_some_and(|expires_at| now_epoch.saturating_add(skew_seconds) >= expires_at) + } +} + +#[derive(Debug, Error)] +pub enum OAuthError { + #[error("invalid OAuth URL: {0}")] + Url(String), + #[error("OAuth requires loopback redirect URI")] + NonLoopbackRedirect, + #[error("OAuth state mismatch")] + StateMismatch, + #[error("OAuth callback missing code")] + MissingCode, + #[error("OAuth callback failed: {0}")] + Callback(String), + #[error("OAuth request failed: {0}")] + Transport(String), + #[error("OAuth returned {status}: {body}")] + Http { status: u16, body: String }, + #[error("OAuth JSON failed: {0}")] + Json(String), + #[error("OAuth timed out")] + Timeout, + #[error("open this OAuth URL manually: {0}")] + BrowserOpen(String), +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + token_type: Option, + expires_in: Option, +} + +pub fn new_pkce_challenge() -> Result { + let verifier = random_url_token(32)?; + Ok(PkceChallenge { + challenge: pkce_s256_challenge(&verifier), + verifier, + state: random_url_token(32)?, + }) +} + +pub fn pkce_s256_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +pub fn authorization_url( + config: &OAuthClientConfig, + pkce: &PkceChallenge, +) -> Result { + validate_loopback_redirect(&config.redirect_uri)?; + let mut url = oauth_endpoint(&config.instance_url, "oauth/authorize")?; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &config.client_id) + .append_pair("redirect_uri", config.redirect_uri.as_str()) + .append_pair("scope", &config.scope) + .append_pair("state", &pkce.state) + .append_pair("code_challenge", &pkce.challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url) +} + +pub fn parse_callback_url( + callback: &Url, + redirect_uri: &Url, + expected_state: &str, +) -> Result { + if callback.path() != redirect_uri.path() { + return Err(OAuthError::Callback("unexpected callback path".into())); + } + let mut code = None; + let mut state = None; + let mut error = None; + for (key, value) in callback.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + _ => {} + } + } + if let Some(error) = error { + return Err(OAuthError::Callback(error)); + } + if state.as_deref() != Some(expected_state) { + return Err(OAuthError::StateMismatch); + } + code.filter(|value| !value.is_empty()) + .ok_or(OAuthError::MissingCode) +} + +pub fn exchange_code( + config: &OAuthClientConfig, + code: &str, + verifier: &str, +) -> Result { + post_token_form( + config, + vec![ + ("grant_type".to_string(), "authorization_code".to_string()), + ("client_id".to_string(), config.client_id.clone()), + ("code".to_string(), code.to_string()), + ( + "redirect_uri".to_string(), + config.redirect_uri.as_str().to_string(), + ), + ("code_verifier".to_string(), verifier.to_string()), + ], + ) +} + +pub fn refresh_access_token( + config: &OAuthClientConfig, + refresh_token: &str, +) -> Result { + post_token_form( + config, + vec![ + ("grant_type".to_string(), "refresh_token".to_string()), + ("client_id".to_string(), config.client_id.clone()), + ("refresh_token".to_string(), refresh_token.to_string()), + ], + ) +} + +pub fn run_loopback_authorization(config: &OAuthClientConfig) -> Result { + validate_loopback_redirect(&config.redirect_uri)?; + let pkce = new_pkce_challenge()?; + let listener = bind_loopback(&config.redirect_uri)?; + let auth_url = authorization_url(config, &pkce)?; + open_browser(auth_url.as_str()) + .map_err(|_| OAuthError::BrowserOpen(auth_url.as_str().to_string()))?; + let code = wait_for_loopback_code( + listener, + &config.redirect_uri, + &pkce.state, + config.timeout_ms, + )?; + exchange_code(config, &code, &pkce.verifier) +} + +fn post_token_form( + config: &OAuthClientConfig, + mut form: Vec<(String, String)>, +) -> Result { + if let Some(secret) = &config.client_secret { + form.push(("client_secret".to_string(), secret.clone())); + } + let endpoint = oauth_endpoint(&config.instance_url, "oauth/token")?; + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_millis(config.timeout_ms))) + .build() + .into(); + let mut response = agent + .post(endpoint.as_str()) + .config() + .http_status_as_error(false) + .build() + .send_form(form) + .map_err(|error| OAuthError::Transport(error.to_string()))?; + + let status = response.status(); + if status.is_client_error() || status.is_server_error() { + let body = truncate_error_body(&response.body_mut().read_to_string().unwrap_or_default()); + return Err(OAuthError::Http { + status: status.as_u16(), + body, + }); + } + + let token = response + .body_mut() + .read_json::() + .map_err(|error| OAuthError::Json(error.to_string()))?; + Ok(token_response_to_set(token, now_epoch())) +} + +fn token_response_to_set(response: TokenResponse, now_epoch: u64) -> OAuthTokenSet { + OAuthTokenSet { + access_token: response.access_token, + refresh_token: response.refresh_token, + token_type: response.token_type.unwrap_or_else(|| "Bearer".into()), + expires_at_epoch: response + .expires_in + .map(|expires_in| now_epoch.saturating_add(expires_in)), + } +} + +fn bind_loopback(redirect_uri: &Url) -> Result { + let host = redirect_uri + .host_str() + .ok_or(OAuthError::NonLoopbackRedirect)?; + let port = redirect_uri.port().ok_or(OAuthError::NonLoopbackRedirect)?; + TcpListener::bind((host, port)).map_err(|error| OAuthError::Transport(error.to_string())) +} + +fn wait_for_loopback_code( + listener: TcpListener, + redirect_uri: &Url, + expected_state: &str, + timeout_ms: u64, +) -> Result { + listener + .set_nonblocking(true) + .map_err(|error| OAuthError::Transport(error.to_string()))?; + let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + let mut buffer = [0u8; 4096]; + let read = stream + .read(&mut buffer) + .map_err(|error| OAuthError::Transport(error.to_string()))?; + let request = String::from_utf8_lossy(&buffer[..read]); + let result = parse_request_line(&request, redirect_uri, expected_state); + let body = if result.is_ok() { + "Lane OAuth complete. You can return to the terminal." + } else { + "Lane OAuth failed. Return to the terminal." + }; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + return result; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return Err(OAuthError::Timeout); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(error) => return Err(OAuthError::Transport(error.to_string())), + } + } +} + +fn parse_request_line( + request: &str, + redirect_uri: &Url, + expected_state: &str, +) -> Result { + let line = request.lines().next().unwrap_or_default(); + let mut parts = line.split_whitespace(); + let method = parts.next().unwrap_or_default(); + let target = parts.next().unwrap_or_default(); + if method != "GET" || target.is_empty() { + return Err(OAuthError::Callback("invalid callback request".into())); + } + let callback = callback_target_url(target, redirect_uri)?; + parse_callback_url(&callback, redirect_uri, expected_state) +} + +fn callback_target_url(target: &str, redirect_uri: &Url) -> Result { + if target.starts_with("http://") || target.starts_with("https://") { + return Url::parse(target).map_err(|error| OAuthError::Url(error.to_string())); + } + let host = redirect_uri + .host_str() + .ok_or(OAuthError::NonLoopbackRedirect)?; + let mut base = format!("{}://{}", redirect_uri.scheme(), host); + if let Some(port) = redirect_uri.port() { + base.push_str(&format!(":{port}")); + } + base.push_str(target); + Url::parse(&base).map_err(|error| OAuthError::Url(error.to_string())) +} + +fn oauth_endpoint(instance_url: &Url, path: &str) -> Result { + let mut base = instance_url.clone(); + if !base.path().ends_with('/') { + let path = format!("{}/", base.path()); + base.set_path(&path); + } + base.join(path.trim_start_matches('/')) + .map_err(|error| OAuthError::Url(error.to_string())) +} + +fn validate_loopback_redirect(redirect_uri: &Url) -> Result<(), OAuthError> { + let host = redirect_uri + .host_str() + .ok_or(OAuthError::NonLoopbackRedirect)?; + let loopback = matches!(host, "127.0.0.1" | "localhost" | "::1"); + if redirect_uri.scheme() == "http" && loopback && redirect_uri.port().is_some() { + Ok(()) + } else { + Err(OAuthError::NonLoopbackRedirect) + } +} + +fn random_url_token(byte_len: usize) -> Result { + let mut bytes = vec![0u8; byte_len]; + getrandom::getrandom(&mut bytes).map_err(|error| OAuthError::Transport(error.to_string()))?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn open_browser(url: &str) -> std::io::Result<()> { + #[cfg(target_os = "macos")] + let mut command = std::process::Command::new("open"); + #[cfg(target_os = "linux")] + let mut command = std::process::Command::new("xdg-open"); + #[cfg(target_os = "windows")] + let mut command = { + let mut cmd = std::process::Command::new("cmd"); + cmd.arg("/C").arg("start").arg(""); + cmd + }; + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let mut command = std::process::Command::new("false"); + + let status = command.arg(url).status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other("browser command failed")) + } +} + +fn truncate_error_body(body: &str) -> String { + body.chars().take(512).collect() +} + +fn now_epoch() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> OAuthClientConfig { + OAuthClientConfig { + instance_url: Url::parse("https://gitlab.example.com/gitlab").unwrap(), + client_id: "client-id".into(), + client_secret: None, + redirect_uri: Url::parse("http://127.0.0.1:8910/oauth/callback").unwrap(), + scope: "api".into(), + timeout_ms: 60_000, + } + } + + #[test] + fn builds_pkce_authorization_url_under_gitlab_subpath() { + let pkce = PkceChallenge { + verifier: "verifier".into(), + challenge: "challenge".into(), + state: "state".into(), + }; + + let url = authorization_url(&config(), &pkce).unwrap(); + + assert_eq!( + url.as_str(), + "https://gitlab.example.com/gitlab/oauth/authorize?response_type=code&client_id=client-id&redirect_uri=http%3A%2F%2F127.0.0.1%3A8910%2Foauth%2Fcallback&scope=api&state=state&code_challenge=challenge&code_challenge_method=S256" + ); + } + + #[test] + fn rejects_callback_with_wrong_state() { + let callback = + Url::parse("http://127.0.0.1:8910/oauth/callback?code=ok&state=bad").unwrap(); + + let err = parse_callback_url(&callback, &config().redirect_uri, "good").unwrap_err(); + + assert!(matches!(err, OAuthError::StateMismatch)); + } + + #[test] + fn parses_valid_callback_code() { + let callback = + Url::parse("http://127.0.0.1:8910/oauth/callback?code=ok&state=good").unwrap(); + + assert_eq!( + parse_callback_url(&callback, &config().redirect_uri, "good").unwrap(), + "ok" + ); + } + + #[test] + fn pkce_challenge_uses_s256_base64url_without_padding() { + assert_eq!( + pkce_s256_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn rejects_non_loopback_redirects() { + let mut cfg = config(); + cfg.redirect_uri = Url::parse("https://example.com/callback").unwrap(); + + let err = authorization_url( + &cfg, + &PkceChallenge { + verifier: "verifier".into(), + challenge: "challenge".into(), + state: "state".into(), + }, + ) + .unwrap_err(); + + assert!(matches!(err, OAuthError::NonLoopbackRedirect)); + } +} diff --git a/src/settings.rs b/src/settings.rs index 544640b..0de3919 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -26,7 +26,6 @@ pub struct GitLabSettings { pub project_id: String, pub token: String, pub default_labels: String, - pub sync_on_startup: bool, } #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] @@ -196,7 +195,7 @@ struct FieldSpec { kind: FieldKind, } -const GITLAB_FIELDS: [FieldSpec; 5] = [ +const GITLAB_FIELDS: [FieldSpec; 4] = [ FieldSpec { label: "Base URL", kind: FieldKind::Text, @@ -213,10 +212,6 @@ const GITLAB_FIELDS: [FieldSpec; 5] = [ label: "Default Labels", kind: FieldKind::Text, }, - FieldSpec { - label: "Sync on startup", - kind: FieldKind::Toggle, - }, ]; const AI_FIELDS: [FieldSpec; 5] = [ @@ -319,7 +314,6 @@ impl SettingsForm { (0, 1) => self.draft.gitlab.project_id.clone(), (0, 2) => self.draft.gitlab.token.clone(), (0, 3) => self.draft.gitlab.default_labels.clone(), - (0, 4) => toggle_str(self.draft.gitlab.sync_on_startup), (1, 0) => self.draft.ai.base_url.clone(), (1, 1) => self.draft.ai.api_key.clone(), (1, 2) => self.draft.ai.model.clone(), @@ -391,7 +385,6 @@ impl SettingsForm { return; } match (self.section, self.focused) { - (0, 4) => self.draft.gitlab.sync_on_startup = !self.draft.gitlab.sync_on_startup, (1, 4) => self.draft.ai.enabled = !self.draft.ai.enabled, (2, 0) => self.draft.appearance.compact = !self.draft.appearance.compact, (2, 1) => self.draft.appearance.show_labels = !self.draft.appearance.show_labels, @@ -572,14 +565,15 @@ mod tests { fn form_input_and_toggle_behavior() { let mut form = SettingsForm::new(Settings::default()); - form.section = 0; - form.focused = 4; // Sync on startup toggle + form.section = 1; + form.focused = 4; // AI enabled toggle form.input_char('x'); assert_eq!(form.field_value(4), "off"); form.toggle(); assert_eq!(form.field_value(4), "on"); + form.section = 0; form.focused = 0; // Base URL text field form.input_char('h'); form.input_char('i'); diff --git a/src/storage.rs b/src/storage.rs index f990d11..a1f3ff2 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -7,8 +7,6 @@ use serde::de::DeserializeOwned; use serde::Serialize; use thiserror::Error; -use crate::model::Flag; - #[derive(Clone)] pub struct FileStore { root: PathBuf, @@ -68,72 +66,15 @@ impl FileStore { } } - pub fn save_sidecar_order(&self, column: &str, ids: &[u64]) -> Result<(), StorageError> { - let path = self.sidecar_order_path(column); - write_json(&path, ids) - } - - pub fn load_sidecar_order(&self, column: &str) -> Result>, StorageError> { - let path = self.sidecar_order_path(column); - match read_json::>(&path) { - Ok(values) => Ok(Some(values)), - Err(StorageError::NotFound) => Ok(None), - Err(err) => Err(err), - } - } - - pub fn save_flag_reason(&self, iid: u64, flag: Flag, reason: &str) -> Result<(), StorageError> { - let path = self.flag_reason_path(iid, flag); - write_json(&path, reason) - } - - pub fn load_flag_reason(&self, iid: u64, flag: Flag) -> Result, StorageError> { - let path = self.flag_reason_path(iid, flag); - match read_json::(&path) { - Ok(value) => Ok(Some(value)), - Err(StorageError::NotFound) => Ok(None), - Err(err) => Err(err), - } - } - - pub fn clear_flag_reason(&self, iid: u64, flag: Flag) -> Result<(), StorageError> { - let path = self.flag_reason_path(iid, flag); - match fs::remove_file(path) { - Ok(_) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(StorageError::Io(err)), - } - } - fn secret_path(&self, key: &str) -> PathBuf { self.scope_dir() .join("secrets") .join(format!("{}.json", sanitize_component(key))) } - fn sidecar_order_path(&self, column: &str) -> PathBuf { - self.sidecar_dir() - .join("orders") - .join(format!("{}.json", sanitize_component(column))) - } - - fn flag_reason_path(&self, iid: u64, flag: Flag) -> PathBuf { - let flag = match flag { - Flag::Blocked => "blocked", - Flag::Reviewing => "reviewing", - }; - self.sidecar_dir() - .join("flags") - .join(format!("{iid}__{flag}.json")) - } - fn scope_dir(&self) -> PathBuf { self.root.join(&self.scope) } - - fn sidecar_dir(&self) -> PathBuf { - self.scope_dir().join("sidecar") - } } pub fn default_root() -> PathBuf { @@ -155,10 +96,14 @@ pub(crate) fn write_json( ) -> Result<(), StorageError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; + if should_harden_dir(parent) { + harden_dir(parent)?; + } } let value = serde_json::to_string_pretty(value) .map_err(|error| StorageError::Invalid(format!("{error}")))?; fs::write(path, value)?; + harden_file(path)?; Ok(()) } @@ -190,6 +135,36 @@ fn sanitize_component(input: &str) -> String { out } +fn should_harden_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "secrets") +} + +#[cfg(unix)] +fn harden_dir(path: &Path) -> Result<(), StorageError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn harden_dir(_path: &Path) -> Result<(), StorageError> { + Ok(()) +} + +#[cfg(unix)] +fn harden_file(path: &Path) -> Result<(), StorageError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn harden_file(_path: &Path) -> Result<(), StorageError> { + Ok(()) +} + #[derive(Debug, Error)] pub enum StorageError { #[error("io {0}")] @@ -215,63 +190,44 @@ mod tests { } #[test] - fn stores_config_secrets_and_sidecar_by_scope() { + fn stores_config_secrets_by_scope() { let root = std::env::temp_dir().join(format!("lane-test-{}", unique_suffix())); let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); store.save_secret("gitlab_token", "glpat-secret").unwrap(); - store.save_sidecar_order("todo", &[101, 102]).unwrap(); - store - .save_flag_reason(101, Flag::Blocked, "blocked reason") - .unwrap(); assert_eq!( store.load_secret("gitlab_token").unwrap().as_deref(), Some("glpat-secret") ); - assert_eq!( - store.load_sidecar_order("todo").unwrap(), - Some(vec![101, 102]) - ); - assert_eq!( - store.load_flag_reason(101, Flag::Blocked).unwrap(), - Some("blocked reason".to_string()) - ); assert!(!format!("{store:?}").contains("glpat-secret")); - assert!(!format!("{store:?}").contains("blocked reason")); let _ = std::fs::remove_dir_all(root); } + #[cfg(unix)] #[test] - fn clearing_flag_reason_removes_sidecar_file() { - let root = std::env::temp_dir().join(format!("lane-test-{}", unique_suffix())); - let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); - - store.save_flag_reason(101, Flag::Blocked, "").unwrap(); - assert_eq!( - store.load_flag_reason(101, Flag::Blocked).unwrap(), - Some(String::new()) - ); - - store.clear_flag_reason(101, Flag::Blocked).unwrap(); - - assert_eq!(store.load_flag_reason(101, Flag::Blocked).unwrap(), None); + fn written_secret_files_are_owner_only() { + use std::os::unix::fs::PermissionsExt; - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn sidecar_order_roundtrips_in_saved_sequence() { let root = std::env::temp_dir().join(format!("lane-test-{}", unique_suffix())); let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); - store.save_sidecar_order("doing", &[205, 201, 207]).unwrap(); + store.save_secret("oauth_access_token", "secret").unwrap(); - assert_eq!( - store.load_sidecar_order("doing").unwrap(), - Some(vec![205, 201, 207]) - ); + let path = root + .join("gitlab.example.com__42") + .join("secrets") + .join("oauth_access_token.json"); + let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + let dir_mode = std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + + assert_eq!(file_mode, 0o600); + assert_eq!(dir_mode, 0o700); let _ = std::fs::remove_dir_all(root); } diff --git a/src/sync.rs b/src/sync.rs index 4918dd9..0f05092 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -217,6 +217,9 @@ mod tests { references: GitLabReferences { full: format!("x/y#{iid}"), }, + assignees: Vec::new(), + milestone: None, + created_at: None, user_notes_count: None, updated_at: None, } diff --git a/src/ui/board.rs b/src/ui/board.rs index fc194e4..5b87446 100644 --- a/src/ui/board.rs +++ b/src/ui/board.rs @@ -78,13 +78,10 @@ fn render_empty_state(frame: &mut Frame, area: Rect, lines: &[&str]) { fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usize) { let state = crate::state_for(column_index); - let active = app.active_column == column_index; let drop_active = drag_over_column(app, column_index); let accent = state_color(state); let border_color = if drop_active { theme::SUCCESS - } else if active { - accent } else { theme::BORDER }; @@ -123,26 +120,23 @@ fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usi } else if rows.is_empty() { render_empty_state(frame, inner, &["No matches"]); } else { - let card_height: u16 = if app.settings.appearance.compact { - 3 - } else { - 5 - }; + let card_height: u16 = + if app.settings.appearance.compact || !app.settings.appearance.show_labels { + 3 + } else { + 4 + }; let gap: u16 = 1; let stride = card_height + gap; let visible = (inner.height / stride.max(1)).max(1) as usize; let scroll = app.scroll[column_index].min(rows.len().saturating_sub(1)); let selected = app.selected[column_index]; - let mut shown = 0usize; - for (visible_index, &absolute_index) in - rows.iter().skip(scroll).take(visible + 1).enumerate() - { + for (visible_index, &absolute_index) in rows.iter().skip(scroll).take(visible).enumerate() { let y = inner.y + visible_index as u16 * stride; if y + card_height > inner.y + inner.height { break; } - shown += 1; let rect = Rect::new(inner.x + 1, y, inner.width.saturating_sub(3), card_height); if app.drag.as_ref().is_some_and(|drag| { drag.moved @@ -152,38 +146,22 @@ fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usi render_lift_placeholder(frame, rect); continue; } - let is_selected = active && selected == absolute_index; + let is_selected = app.active_column == column_index && selected == absolute_index; let issue = &app.columns[column_index][absolute_index]; + let target = ClickTarget::Issue { + column: column_index, + index: absolute_index, + }; render_card( frame, rect, issue, is_selected, + app.is_hovered(&target), accent, &app.settings.appearance, ); - app.add_hitbox( - rect, - ClickTarget::Issue { - column: column_index, - index: absolute_index, - }, - ); - } - - let remaining = rows.len().saturating_sub(scroll + shown); - if remaining > 0 && inner.height > 0 { - let plural = if remaining == 1 { "" } else { "s" }; - frame.render_widget( - Paragraph::new(format!("+{remaining} more issue{plural}")) - .style(Style::default().fg(theme::TEXT_MUTED)), - Rect::new( - inner.x + 1, - inner.y + inner.height - 1, - inner.width.saturating_sub(2), - 1, - ), - ); + app.add_hitbox(rect, target); } } @@ -200,15 +178,18 @@ pub(crate) fn render_card( area: Rect, issue: &crate::Issue, selected: bool, + hovered: bool, accent: Color, appearance: &AppearanceSettings, ) { let border_color = if selected { theme::BORDER_FOCUS + } else if hovered { + theme::INFO } else { theme::BORDER }; - let bg = if selected { + let bg = if selected || hovered { theme::SURFACE_ELEVATED } else { theme::SURFACE @@ -250,19 +231,6 @@ pub(crate) fn render_card( lines.push(Line::from(title_spans)); if appearance.show_labels && !appearance.compact && !issue.labels.is_empty() { - lines.push(Line::from(Span::styled( - format!( - " {}", - issue - .labels - .iter() - .map(|label| label.to_uppercase()) - .collect::>() - .join(" / ") - ), - Style::default().fg(theme::TEXT_MUTED), - ))); - let mut label_spans = vec![Span::raw(" ")]; for (index, label) in issue.labels.iter().enumerate() { if index > 0 { diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index d0b159c..579162c 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -28,7 +28,8 @@ fn spans_width(spans: &[Span]) -> u16 { fn connection_summary_spans(app: &App) -> Vec> { match (&app.connected_project, &app.connected_user) { (Some(project), Some(user)) => vec![ - Span::styled("● Connected", Style::default().fg(theme::SUCCESS)), + Span::styled("GitLab: ", Style::default().fg(theme::TEXT_SECONDARY)), + Span::styled("Connected", Style::default().fg(theme::SUCCESS)), Span::styled(" • ", Style::default().fg(theme::TEXT_MUTED)), Span::styled( format!("{} @{}", project.path_with_namespace, user.username), @@ -36,7 +37,8 @@ fn connection_summary_spans(app: &App) -> Vec> { ), ], (Some(project), None) => vec![ - Span::styled("● Connected", Style::default().fg(theme::SUCCESS)), + Span::styled("GitLab: ", Style::default().fg(theme::TEXT_SECONDARY)), + Span::styled("Connected", Style::default().fg(theme::SUCCESS)), Span::styled(" • ", Style::default().fg(theme::TEXT_MUTED)), Span::styled( project.path_with_namespace.clone(), @@ -44,7 +46,8 @@ fn connection_summary_spans(app: &App) -> Vec> { ), ], (None, Some(user)) => vec![ - Span::styled("● Connected", Style::default().fg(theme::SUCCESS)), + Span::styled("GitLab: ", Style::default().fg(theme::TEXT_SECONDARY)), + Span::styled("Connected", Style::default().fg(theme::SUCCESS)), Span::styled( format!(" @{}", user.username), Style::default().fg(theme::TEXT_SECONDARY), @@ -62,7 +65,7 @@ fn connection_summary_spans(app: &App) -> Vec> { )] } else { vec![Span::styled( - "○ offline", + "GitLab: offline", Style::default().fg(theme::TEXT_MUTED), )] } @@ -81,17 +84,10 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { frame.render_widget(block, area); let buttons = [ - ("Connect", 9u16, false, ClickTarget::Connect), - ("Sync", 6u16, false, ClickTarget::SyncAll), - ("New Issue", 11u16, false, ClickTarget::NewIssue), - ( - "Generate Stories", - 18u16, - true, - ClickTarget::GenerateStories, - ), - ("? Help", 8u16, false, ClickTarget::Help), - ("Settings", 10u16, false, ClickTarget::Settings), + ("? Help", 9u16, false, ClickTarget::Help), + ("c Connect", 11u16, false, ClickTarget::Connect), + ("s Sync", 8u16, false, ClickTarget::SyncAll), + ("Ctrl+, Settings", 17u16, false, ClickTarget::Settings), ]; let buttons_total_width = buttons.iter().map(|(_, width, _, _)| *width).sum::() + (buttons.len().saturating_sub(1) as u16 * 2); @@ -129,7 +125,13 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { Style::default().fg(theme::TEXT_MUTED), )); } - let summary_width = spans_width(&summary_spans).min(left_limit.saturating_sub(x)); + let search_reserve = if left_limit.saturating_sub(x) > 48 { + 20 + } else { + 0 + }; + let summary_width = + spans_width(&summary_spans).min(left_limit.saturating_sub(x + search_reserve)); if summary_width > 0 { frame.render_widget( Paragraph::new(Line::from(summary_spans)), @@ -158,7 +160,7 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { spans } else { vec![Span::styled( - " Search issues… / ", + "⌕ Search issues... / ", Style::default().fg(theme::TEXT_MUTED).bg(theme::SURFACE), )] }; @@ -181,7 +183,8 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { } else { theme::button_secondary_style() }; - super::render_button(frame, rect, label, style); + let hovered = app.is_hovered(&target); + super::render_button_with_hover(frame, rect, label, style, hovered); app.add_hitbox(rect, target); bx = bx.saturating_add(width + 2); } @@ -262,7 +265,7 @@ pub fn render_status(frame: &mut Frame, area: Rect, app: &App) { return; } - let shortcuts = footer_shortcuts(app); + let shortcuts = footer_shortcuts(app, inner.width); frame.render_widget( Paragraph::new(super::shortcut_line(&shortcuts)).alignment(Alignment::Center), inner, @@ -296,18 +299,26 @@ fn render_connecting_gauge(frame: &mut Frame, area: Rect, app: &App) { frame.render_widget(gauge, area); } -pub(crate) fn footer_shortcuts(app: &App) -> Vec<(&'static str, &'static str)> { +pub(crate) fn footer_shortcuts(app: &App, width: u16) -> Vec<(&'static str, &'static str)> { if app.overlay.is_some() { vec![("Esc", "Close"), ("q", "Quit")] - } else { + } else if width < 72 { vec![ - ("c", "Connect"), - ("r", "Sync"), ("n", "New"), ("Enter", "Open"), - ("b", "Block"), + ("s", "Sync"), + ("q", "Quit"), + ] + } else { + vec![ + ("↑↓", "Nav"), + ("←→", "Move col"), + ("Enter", "Open"), + ("n", "New issue"), + ("e", "Edit"), + ("g", "Stories"), ("/", "Search"), - ("Arrows", "Move"), + ("s", "Sync"), (",", "Settings"), ("?", "Help"), ("q", "Quit"), @@ -337,6 +348,7 @@ pub(crate) fn status_shortcuts(app: &App, width: u16) -> Vec<(&'static str, &'st ("c", "Connect"), ("r", "Sync"), ("n", "New"), + ("g", "Generate"), ("Enter", "Open"), ("q", "Quit"), ] diff --git a/src/ui/help.rs b/src/ui/help.rs index c4b3795..c95d940 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -23,7 +23,11 @@ pub fn render_help_modal(frame: &mut Frame, area: Rect, app: &mut App) { // Two-column shortcut layout, like the design mock. let columns = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .constraints([ + Constraint::Percentage(49), + Constraint::Length(2), + Constraint::Percentage(49), + ]) .split(content_area); let (left, right) = help_columns(); frame.render_widget( @@ -36,7 +40,7 @@ pub fn render_help_modal(frame: &mut Frame, area: Rect, app: &mut App) { Paragraph::new(right) .wrap(Wrap { trim: false }) .scroll((app.help_scroll as u16, 0)), - columns[1], + columns[2], ); frame.render_widget( @@ -59,14 +63,14 @@ fn help_columns() -> (Vec>, Vec>) { &mut left, "Global", &[ - ("/", "Search issues"), + ("/", "Search"), ("?", "This help"), - ("Ctrl+K", "Action palette"), + ("Ctrl+K", "Palette"), (", (comma)", "Settings"), ("c", "Connect"), ("s / r", "Sync"), - ("Ctrl+C", "Copy field / issue to clipboard"), - ("Shift+Drag", "Select text (terminal native)"), + ("Ctrl+C", "Copy field"), + ("Drag", "Text select"), ("q", "Quit"), ], ); @@ -74,12 +78,12 @@ fn help_columns() -> (Vec>, Vec>) { &mut left, "Board", &[ - ("\u{2191}\u{2193} / j k", "Move between issues"), - ("\u{2190}\u{2192} / h l", "Move between columns"), + ("\u{2191}\u{2193} / j k", "Move issues"), + ("\u{2190}\u{2192} / h l", "Move columns"), ("Enter / e", "Open issue"), ("n", "New issue"), - ("g", "Generate stories"), - ("b", "Toggle blocked"), + ("g", "Stories"), + ("b", "Block"), ("Esc", "Clear search"), ], ); @@ -88,25 +92,25 @@ fn help_columns() -> (Vec>, Vec>) { &mut right, "Modals", &[ - ("Tab", "Cycle Title / Description / Labels"), - ("Enter", "Confirm / newline"), + ("Tab", "Next field"), + ("Enter", "Confirm/newline"), ("Ctrl+S", "Save"), - ("Esc", "Close / cancel"), + ("Esc", "Close/cancel"), ], ); push_section( &mut right, "Issue editor", &[ - ("Ctrl+A", "AI improve description"), - ("Ctrl+Y", "AI acceptance criteria"), - ("Ctrl+L", "AI suggest labels"), - ("Ctrl+P", "Markdown preview"), - ("Ctrl+B/I/K/E", "Markdown snippets"), - ("Ctrl+T", "Task checkbox snippet"), - ("PgUp/PgDn", "Scroll description"), - ("Ctrl+N", "Notes (from details)"), - ("Paste", "Terminal paste lands in the focused field"), + ("Ctrl+A", "AI improve"), + ("Ctrl+Y", "AI criteria"), + ("Ctrl+L", "AI labels"), + ("Ctrl+P", "Preview"), + ("Ctrl+B/I/K/E", "Markdown"), + ("Ctrl+T", "Task checkbox"), + ("PgUp/PgDn", "Scroll"), + ("Ctrl+N", "Notes"), + ("Paste", "Paste field"), ], ); push_section( @@ -141,7 +145,7 @@ fn push_section(lines: &mut Vec>, title: &str, rows: &[(&str, &str ))); for (key, description) in rows { lines.push(Line::from(vec![ - Span::styled(format!("{key:>9}"), theme::keycap_style()), + Span::styled(format!("{key:>7}"), theme::keycap_style()), Span::raw(" "), Span::styled( (*description).to_string(), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8994796..948b665 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -41,9 +41,10 @@ pub fn render(frame: &mut Frame, app: &mut App) { chrome::render_topbar(frame, shell[0], app); if details_as_panel { + let panel_width = ((root.width as f32 * 0.34) as u16).clamp(42, 56); let split = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(50), Constraint::Length(44)]) + .constraints([Constraint::Min(50), Constraint::Length(panel_width)]) .split(shell[1]); board::render_board(frame, split[0], app); modals::render_details_panel(frame, split[1], app); @@ -128,6 +129,15 @@ pub(crate) fn modal_frame( Block::new().style(Style::default().bg(theme::SURFACE_ELEVATED)), rect, ); + frame.render_widget( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(theme::BORDER_FOCUS)) + .style(Style::default().bg(theme::SURFACE_ELEVATED)), + rect, + ); let title_text = format!(" {title} "); let title_width = (title_text.chars().count() as u16).min(rect.width); @@ -154,6 +164,21 @@ pub(crate) fn modal_frame( /// Buttons render as half-block pills — rounded caps around a filled label — /// so they read as controls, not as colored text. pub(crate) fn render_button(frame: &mut Frame, area: Rect, label: &str, style: Style) { + render_button_with_hover(frame, area, label, style, false); +} + +pub(crate) fn render_button_with_hover( + frame: &mut Frame, + area: Rect, + label: &str, + style: Style, + hovered: bool, +) { + let style = if hovered { + style.add_modifier(Modifier::REVERSED) + } else { + style + }; let cap_color = style.bg.unwrap_or(theme::SURFACE_ELEVATED); let inner_width = area.width.saturating_sub(2) as usize; let text: String = label.chars().take(inner_width).collect(); @@ -175,7 +200,7 @@ pub(crate) fn shortcut_line_width(shortcuts: &[(&str, &str)]) -> u16 { shortcuts .iter() .enumerate() - .map(|(index, (key, label))| key.len() + 1 + label.len() + usize::from(index > 0) * 3) + .map(|(index, (key, label))| key.len() + 1 + label.len() + usize::from(index > 0) * 2) .sum::() .try_into() .unwrap_or(u16::MAX) @@ -185,7 +210,7 @@ pub(crate) fn shortcut_line(shortcuts: &[(&str, &str)]) -> Line<'static> { let mut spans = Vec::new(); for (index, (key, label)) in shortcuts.iter().enumerate() { if index > 0 { - spans.push(Span::raw(" ")); + spans.push(Span::raw(" ")); } spans.push(Span::styled(key.to_string(), theme::keycap_style())); spans.push(Span::raw(format!(" {label}"))); @@ -197,18 +222,7 @@ pub(crate) fn shortcut_line(shortcuts: &[(&str, &str)]) -> Line<'static> { mod tests { use super::*; use crate::model::{BoardState, GitLabNote, GitLabProject, GitLabUser, IssueRecord, Origin}; - use crate::{ClickTarget, DragState, DropTarget, Issue, LaneState, StatusKind}; - - fn char_offset(haystack: &str, needle: &str) -> usize { - haystack - .char_indices() - .find_map(|(byte_index, _)| { - haystack[byte_index..] - .starts_with(needle) - .then(|| haystack[..byte_index].chars().count()) - }) - .expect("visible text") - } + use crate::{ClickTarget, DragState, DropTarget, Issue, LaneState, StatusKind, StoryTab}; fn render_to_string(width: u16, height: u16, app: &mut App) -> String { let backend = ratatui::backend::TestBackend::new(width, height); @@ -243,6 +257,10 @@ mod tests { buffer.content()[y * width + x].bg } + fn fg_at(buffer: &ratatui::buffer::Buffer, width: usize, x: usize, y: usize) -> Color { + buffer.content()[y * width + x].fg + } + fn demo_issue(iid: u64, title: &str, state: LaneState, blocked: bool) -> Issue { Issue { iid, @@ -252,6 +270,13 @@ mod tests { labels: vec!["ui".into(), "polish".into()], blocked, due_date: None, + assignees: Vec::new(), + milestone: None, + created_at: None, + updated_at: None, + note_count: None, + weight: None, + reference: format!("me/lane#{iid}"), source: None, } } @@ -267,6 +292,9 @@ mod tests { labels: Vec::new(), due_date: None, weight: None, + assignees: Vec::new(), + milestone: None, + created_at: None, web_url: format!("https://gitlab.example.com/me/lane/-/issues/{iid}"), reference: format!("me/lane#{iid}"), flags: Vec::new(), @@ -288,10 +316,9 @@ mod tests { .expect("board render"); let buffer = terminal.backend().buffer(); - // Row 4 is board chrome; row 34 is the status bar interior. Cell (1,1) - // is skipped: the accent brand chip sits there by design. + // Row 4 is board chrome. Cell (1,1) is skipped: the accent brand + // chip sits there by design. assert_eq!(bg_at(buffer, 120, 1, 4), theme::BG); - assert_eq!(bg_at(buffer, 120, 1, 34), theme::BG); } #[test] @@ -329,8 +356,8 @@ mod tests { assert!(topbar_row.contains("Lane"), "topbar: {topbar_row:?}"); assert!(topbar_row.contains("Connect"), "topbar: {topbar_row:?}"); assert!(topbar_row.contains("Sync"), "topbar: {topbar_row:?}"); - assert!(screen.contains("New Issue")); - assert!(screen.contains("Generate Stories")); + assert!(topbar_row.contains("Help"), "topbar: {topbar_row:?}"); + assert!(topbar_row.contains("Settings"), "topbar: {topbar_row:?}"); assert!(!screen.contains("Command")); assert!(app .hitboxes @@ -343,22 +370,19 @@ mod tests { assert!(app .hitboxes .iter() - .any(|hitbox| hitbox.target == ClickTarget::NewIssue)); - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::GenerateStories)); + .any(|hitbox| hitbox.target == ClickTarget::Help)); } #[test] - fn topbar_renders_generate_stories_next_to_new_issue() { + fn topbar_keeps_primary_board_actions_out_of_button_rail() { let mut app = App::demo(); let screen = render_to_string(120, 36, &mut app); + let topbar_row = screen_row(&screen, 120, 1); - let new_issue = char_offset(&screen, "New Issue"); - let generate = char_offset(&screen, "Generate Stories"); - - assert!(generate > new_issue); + assert!(!topbar_row.contains("New Issue")); + assert!(!topbar_row.contains("Generate Stories")); + assert!(screen.contains("New issue")); + assert!(screen.contains("Stories")); } #[test] @@ -390,6 +414,7 @@ mod tests { assert!(screen.contains("Issue #101")); assert!(screen.contains("Description")); assert!(screen.contains("Edit")); + assert!(screen.contains("Notes")); assert!(screen.contains("Move")); // Board hitboxes remain live alongside the panel's actions. assert!(app @@ -404,6 +429,10 @@ mod tests { .hitboxes .iter() .any(|hitbox| hitbox.target == ClickTarget::MoveIssueRight)); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Notes)); } #[test] @@ -417,7 +446,10 @@ mod tests { assert!(app.hitboxes.iter().all(|hitbox| { matches!( hitbox.target, - ClickTarget::EditIssue | ClickTarget::MoveIssueRight | ClickTarget::Cancel + ClickTarget::EditIssue + | ClickTarget::Notes + | ClickTarget::MoveIssueRight + | ClickTarget::Cancel ) })); } @@ -439,12 +471,47 @@ mod tests { } #[test] - fn board_shows_more_issues_footer_when_column_overflows() { + fn board_hides_overflow_scroll_footer() { let mut app = App::demo(); let screen = render_to_string(120, 36, &mut app); - assert!(screen.contains("more issue"), "screen: missing +N footer"); + assert!(!screen.contains("more issue"), "screen: shows +N footer"); + } + + #[test] + fn issue_card_labels_render_once_on_their_own_row() { + let backend = ratatui::backend::TestBackend::new(48, 4); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let issue = demo_issue(902, "Card labels", LaneState::Todo, false); + let appearance = crate::settings::AppearanceSettings::default(); + + terminal + .draw(|frame| { + board::render_card( + frame, + Rect::new(0, 0, 48, 4), + &issue, + false, + false, + theme::WARNING, + &appearance, + ) + }) + .expect("card render"); + + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + let label_row: String = screen.chars().skip(48 * 2).take(48).collect(); + + assert!(label_row.contains("ui")); + assert!(label_row.contains("polish")); + assert!(!screen.contains("UI / POLISH")); } #[test] @@ -472,17 +539,49 @@ mod tests { assert!(!screen.contains("IID")); } + #[test] + fn board_hover_changes_card_border_color() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("render"); + let target = ClickTarget::Issue { + column: 0, + index: 1, + }; + let rect = app + .hitboxes + .iter() + .find(|hitbox| hitbox.target == target) + .expect("issue hitbox") + .rect; + + app.hover = Some(target); + terminal + .draw(|frame| render(frame, &mut app)) + .expect("hover render"); + + let buffer = terminal.backend().buffer(); + assert_eq!( + fg_at(buffer, 120, rect.x as usize, rect.y as usize), + theme::INFO + ); + } + #[test] fn footer_is_keyboard_shortcuts_only() { let mut app = App::demo(); let screen = render_to_string(120, 36, &mut app); let footer: String = screen.chars().skip(120 * 33).collect(); - assert!(footer.contains("c Connect")); - assert!(footer.contains("r Sync")); - assert!(footer.contains("n New")); + assert!(footer.contains("↑↓ Nav")); + assert!(footer.contains("←→ Move col")); + assert!(footer.contains("n New issue")); assert!(footer.contains("Enter Open")); - assert!(footer.contains("b Block")); + assert!(footer.contains("g Stories")); assert!(footer.contains("/ Search")); assert!(footer.contains("? Help")); assert!(footer.contains("q Quit")); @@ -903,6 +1002,7 @@ mod tests { Rect::new(0, 0, 48, 4), &blocked, false, + false, theme::WARNING, &appearance, ) @@ -1238,6 +1338,10 @@ mod tests { assert!(screen.contains("Preview")); assert!(screen.contains("Generated (0)")); assert!(screen.contains("Create Selected")); + assert!(app.hitboxes.iter().any(|hitbox| hitbox.target + == ClickTarget::StoryTab { + tab: StoryTab::Preview + })); } #[test] @@ -1254,6 +1358,26 @@ mod tests { ); } + #[test] + fn generate_stories_generated_status_uses_global_status_only() { + let mut app = App::demo(); + app.open_generate_stories(); + app.set_generated_stories(vec![crate::model::GeneratedStory { + title: "Draft".into(), + description: "Body".into(), + acceptance_criteria: vec![], + labels: vec![], + priority: None, + size: None, + }]); + app.story_focus = crate::StoryModalFocus::List; + app.story_status = Some("1 story generated. Select stories to create.".into()); + + let screen = render_to_string(120, 40, &mut app); + + assert_eq!(screen.matches("1 story generated").count(), 1); + } + #[test] fn settings_modal_renders_tabs_and_masks_secrets() { let mut app = App::demo(); @@ -1277,6 +1401,40 @@ mod tests { .hitboxes .iter() .any(|hitbox| hitbox.target == ClickTarget::SettingsSection { index: 1 })); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::SettingsField { index: 0 })); + } + + #[test] + fn settings_hover_changes_field_border_color() { + let backend = ratatui::backend::TestBackend::new(120, 40); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_settings(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("render"); + let target = ClickTarget::SettingsField { index: 1 }; + let rect = app + .hitboxes + .iter() + .find(|hitbox| hitbox.target == target) + .expect("settings field hitbox") + .rect; + + app.hover = Some(target); + terminal + .draw(|frame| render(frame, &mut app)) + .expect("hover render"); + + let buffer = terminal.backend().buffer(); + assert_eq!( + fg_at(buffer, 120, rect.x as usize, rect.y as usize), + theme::BORDER_FOCUS + ); } #[test] diff --git a/src/ui/modals.rs b/src/ui/modals.rs index 3f04832..e4fba66 100644 --- a/src/ui/modals.rs +++ b/src/ui/modals.rs @@ -2,17 +2,14 @@ use ratatui::{ prelude::*, - widgets::{ - Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, - Wrap, - }, + widgets::{Block, BorderType, Borders, Paragraph, Wrap}, }; use crate::theme; use crate::{App, ClickTarget, EditorField}; use super::markdown::description_lines; -use super::{modal_frame, render_button}; +use super::{modal_frame, render_button, render_button_with_hover}; pub fn render_issue_modal(frame: &mut Frame, area: Rect, app: &mut App, is_new: bool) { let issue_id = if is_new { @@ -27,7 +24,6 @@ pub fn render_issue_modal(frame: &mut Frame, area: Rect, app: &mut App, is_new: let chrome = modal_frame(frame, area, 62, 20, &format!("Issue #{issue_id}")); let lines = issue_editor_lines(app); - let total_lines = lines.len(); frame.render_widget( Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) @@ -37,35 +33,10 @@ pub fn render_issue_modal(frame: &mut Frame, area: Rect, app: &mut App, is_new: )), chrome.body, ); - render_editor_scrollbar( - frame, - chrome.body, - total_lines, - app.editor_description_scroll, - ); render_modal_footer(frame, app, chrome.rect, chrome.footer); } -/// Vertical scrollbar on the right edge of the editor body whenever the -/// content is taller than the viewport. -fn render_editor_scrollbar(frame: &mut Frame, body: Rect, total_lines: usize, position: usize) { - let viewport = body.height as usize; - if total_lines <= viewport || body.width == 0 { - return; - } - let mut state = ScrollbarState::new(total_lines.saturating_sub(viewport)).position(position); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(None) - .end_symbol(None) - .thumb_style(Style::default().fg(theme::BORDER_FOCUS)) - .track_style(Style::default().fg(theme::BORDER)), - body, - &mut state, - ); -} - /// Read-only issue view rendered as a right-hand side panel on wide /// terminals: metadata, rendered markdown description (acceptance criteria /// checkboxes included), and Edit / Move / Close actions. @@ -93,8 +64,7 @@ pub fn render_details_panel(frame: &mut Frame, area: Rect, app: &mut App) { inner.width.saturating_sub(2), inner.height.saturating_sub(2), ); - let lines = issue_view_lines(app, &issue); - let total_lines = lines.len(); + let lines = issue_view_lines(app, &issue, false); frame.render_widget( Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) @@ -104,7 +74,6 @@ pub fn render_details_panel(frame: &mut Frame, area: Rect, app: &mut App) { )), body, ); - render_editor_scrollbar(frame, body, total_lines, app.editor_description_scroll); render_details_actions( frame, @@ -126,8 +95,7 @@ pub fn render_details_view_modal(frame: &mut Frame, area: Rect, app: &mut App) { let chrome = modal_frame(frame, area, 62, 20, &format!("Issue #{}", issue.iid)); - let lines = issue_view_lines(app, &issue); - let total_lines = lines.len(); + let lines = issue_view_lines(app, &issue, true); frame.render_widget( Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) @@ -137,12 +105,6 @@ pub fn render_details_view_modal(frame: &mut Frame, area: Rect, app: &mut App) { )), chrome.body, ); - render_editor_scrollbar( - frame, - chrome.body, - total_lines, - app.editor_description_scroll, - ); frame.render_widget( Paragraph::new(" ").style(Style::default().bg(theme::SURFACE_ELEVATED)), @@ -159,19 +121,50 @@ pub fn render_details_view_modal(frame: &mut Frame, area: Rect, app: &mut App) { /// Edit / Move / Close action row shared by the panel and the view modal. fn render_details_actions(frame: &mut Frame, app: &mut App, footer: Rect) { let edit = Rect::new(footer.x, footer.y, 8, 1); - let move_right = Rect::new(footer.x + 10, footer.y, 8, 1); - let close = Rect::new(footer.x + 20, footer.y, 9, 1); - render_button(frame, edit, "e Edit", theme::button_primary_style()); - render_button(frame, move_right, "m Move", theme::button_secondary_style()); - render_button(frame, close, "x Close", theme::button_secondary_style()); + let notes = Rect::new(footer.x + 10, footer.y, 9, 1); + let move_right = Rect::new(footer.x + 21, footer.y, 8, 1); + let close = Rect::new(footer.x + 31, footer.y, 9, 1); + render_button_with_hover( + frame, + edit, + "e Edit", + theme::button_primary_style(), + app.is_hovered(&ClickTarget::EditIssue), + ); + render_button_with_hover( + frame, + notes, + "n Notes", + theme::button_secondary_style(), + app.is_hovered(&ClickTarget::Notes), + ); + render_button_with_hover( + frame, + move_right, + "m Move", + theme::button_secondary_style(), + app.is_hovered(&ClickTarget::MoveIssueRight), + ); + render_button_with_hover( + frame, + close, + "x Close", + theme::button_secondary_style(), + app.is_hovered(&ClickTarget::Cancel), + ); app.add_hitbox(edit, ClickTarget::EditIssue); + app.add_hitbox(notes, ClickTarget::Notes); app.add_hitbox(move_right, ClickTarget::MoveIssueRight); app.add_hitbox(close, ClickTarget::Cancel); } /// Content of the read-only issue view: title, state chip, metadata, and the /// rendered markdown description. -fn issue_view_lines(app: &App, issue: &crate::Issue) -> Vec> { +fn issue_view_lines( + app: &App, + issue: &crate::Issue, + include_shortcuts: bool, +) -> Vec> { let accent = super::board::state_color(issue.state); let mut lines = vec![ Line::from(vec![ @@ -228,10 +221,19 @@ fn issue_view_lines(app: &App, issue: &crate::Issue) -> Vec> { lines.push(Line::from(meta)); if let Some(due) = &issue.due_date { - lines.push(Line::from(vec![ - Span::styled("Due ", Style::default().fg(theme::TEXT_MUTED)), - Span::styled(due.clone(), Style::default().fg(theme::TEXT_SECONDARY)), - ])); + lines.push(meta_line("Due", due.clone())); + } + if !issue.assignees.is_empty() { + lines.push(meta_line("Assignee", issue.assignees.join(", "))); + } + if let Some(milestone) = &issue.milestone { + lines.push(meta_line("Milestone", milestone.clone())); + } + if let Some(weight) = issue.weight { + lines.push(meta_line("Weight", weight.to_string())); + } + if let Some(note_count) = issue.note_count { + lines.push(meta_line("Notes", note_count.to_string())); } if let Some(source) = issue.source { lines.push(Line::from(vec![ @@ -242,6 +244,15 @@ fn issue_view_lines(app: &App, issue: &crate::Issue) -> Vec> { ), ])); } + if !issue.reference.is_empty() { + lines.push(meta_line("Reference", issue.reference.clone())); + } + if let Some(created) = &issue.created_at { + lines.push(meta_line("Created", short_timestamp(created))); + } + if let Some(updated) = &issue.updated_at { + lines.push(meta_line("Updated", short_timestamp(updated))); + } lines.push(Line::raw("")); lines.push(Line::from(Span::styled( @@ -253,15 +264,34 @@ fn issue_view_lines(app: &App, issue: &crate::Issue) -> Vec> { lines.extend(super::markdown::markdown_preview_lines( &app.editor_description, )); - lines.push(Line::raw("")); - lines.push(Line::from(Span::styled( - "e Edit. m Move. Ctrl+N Notes. b Block. Ctrl+C Copy. Esc Close.", - Style::default().fg(theme::TEXT_MUTED), - ))); + if include_shortcuts { + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "e Edit. n Notes. m Move. b Block. Ctrl+C Copy. Esc Close.", + Style::default().fg(theme::TEXT_MUTED), + ))); + } lines } +fn meta_line(label: &str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{label:<10}"), + Style::default().fg(theme::TEXT_MUTED), + ), + Span::styled(value, Style::default().fg(theme::TEXT_SECONDARY)), + ]) +} + +fn short_timestamp(value: &str) -> String { + value + .split_once('T') + .map(|(date, _)| date.to_string()) + .unwrap_or_else(|| value.to_string()) +} + /// The shared field stack for the issue editor: header, title field, /// description (source or markdown preview), labels, and key hints. fn issue_editor_lines(app: &App) -> Vec> { diff --git a/src/ui/settings_ui.rs b/src/ui/settings_ui.rs index 9aff83a..b7e87dd 100644 --- a/src/ui/settings_ui.rs +++ b/src/ui/settings_ui.rs @@ -42,9 +42,9 @@ pub fn render_settings_modal(frame: &mut Frame, area: Rect, app: &mut App) { for field in 0..form.field_count() { let row = rows[field + 1]; if form.field_is_toggle(field) { - render_toggle(frame, row, &form, field); + render_toggle(frame, row, app, &form, field); } else { - render_input_box(frame, row, &form, field); + render_input_box(frame, row, app, &form, field); } } @@ -66,7 +66,7 @@ pub fn render_settings_modal(frame: &mut Frame, area: Rect, app: &mut App) { let hint_row = rows[rows.len() - 1]; frame.render_widget( Paragraph::new(Span::styled( - "Tab next field. \u{2190}/\u{2192} section. Space toggle. Ctrl+S save. Esc close.", + "Tab field. \u{2190}/\u{2192} section. Space toggle. Ctrl+S save. Esc close.", Style::default().fg(theme::TEXT_MUTED), )), hint_row, @@ -113,12 +113,19 @@ fn render_tab_row(frame: &mut Frame, area: Rect, form: &SettingsForm, app: &mut /// A text or secret field as a bordered input box; the border carries focus /// and error state, the title carries the label and inline validation. -fn render_input_box(frame: &mut Frame, area: Rect, form: &SettingsForm, index: usize) { +fn render_input_box( + frame: &mut Frame, + area: Rect, + app: &mut App, + form: &SettingsForm, + index: usize, +) { let focused = form.focused == index; + let hovered = app.is_hovered(&ClickTarget::SettingsField { index }); let error = form.error_for(form.section, index); let border_color = if error.is_some() { theme::ERROR - } else if focused { + } else if focused || hovered { theme::BORDER_FOCUS } else { theme::BORDER @@ -168,14 +175,16 @@ fn render_input_box(frame: &mut Frame, area: Rect, form: &SettingsForm, index: u ) }; frame.render_widget(Paragraph::new(Line::from(content)), inner); + app.add_hitbox(area, ClickTarget::SettingsField { index }); } -fn render_toggle(frame: &mut Frame, area: Rect, form: &SettingsForm, index: usize) { +fn render_toggle(frame: &mut Frame, area: Rect, app: &mut App, form: &SettingsForm, index: usize) { let focused = form.focused == index; + let hovered = app.is_hovered(&ClickTarget::SettingsField { index }); let on = form.field_value(index) == "on"; let line = Line::from(vec![ Span::styled( - if focused { "▸ " } else { " " }, + if focused || hovered { "▸ " } else { " " }, Style::default().fg(theme::ACCENT), ), Span::styled( @@ -196,4 +205,5 @@ fn render_toggle(frame: &mut Frame, area: Rect, form: &SettingsForm, index: usiz ), ]); frame.render_widget(Paragraph::new(line), area); + app.add_hitbox(area, ClickTarget::SettingsField { index }); } diff --git a/src/ui/stories.rs b/src/ui/stories.rs index 17bf5af..73aecea 100644 --- a/src/ui/stories.rs +++ b/src/ui/stories.rs @@ -13,39 +13,62 @@ use super::{modal_frame, render_button, ModalChrome}; pub fn render_generate_stories_modal(frame: &mut Frame, area: Rect, app: &mut App) { let ModalChrome { rect, body, footer } = modal_frame(frame, area, 76, 24, "Generate Stories"); + let status_line = status_line(app); + let mut constraints = vec![ + Constraint::Length(1), // tab bar + Constraint::Length(1), // gap + Constraint::Min(3), // tab content + ]; + if status_line.is_some() { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(1)); // hint let rows = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // tab bar - Constraint::Length(1), // gap - Constraint::Min(3), // tab content - Constraint::Length(1), // status - Constraint::Length(1), // hint - ]) + .constraints(constraints) .split(body); - let (tab_row, content_area, status_row, hint_row) = (rows[0], rows[2], rows[3], rows[4]); + let tab_row = rows[0]; + let content_area = rows[2]; + let hint_row = rows[rows.len() - 1]; let active_tab = match app.story_tab() { StoryTab::Edit => 0, StoryTab::Preview => 1, StoryTab::Generated => 2, }; - let tabs = Tabs::new(vec![ + let tab_titles = vec![ "Edit".to_string(), "Preview".to_string(), format!("Generated ({})", app.generated_stories.len()), - ]) - .select(active_tab) - .style(Style::default().fg(theme::TEXT_SECONDARY)) - .highlight_style( - Style::default() - .fg(Color::Black) - .bg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - ) - .divider(Span::styled("│", Style::default().fg(theme::BORDER))); + ]; + let tabs = Tabs::new(tab_titles.clone()) + .select(active_tab) + .style(Style::default().fg(theme::TEXT_SECONDARY)) + .highlight_style( + Style::default() + .fg(Color::Black) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) + .divider(Span::styled("│", Style::default().fg(theme::BORDER))); frame.render_widget(tabs, tab_row); + let mut tab_x = tab_row.x; + for (index, title) in tab_titles.iter().enumerate() { + let width = (title.chars().count() as u16 + 2).min(tab_row.right().saturating_sub(tab_x)); + if width > 0 { + let tab = match index { + 0 => StoryTab::Edit, + 1 => StoryTab::Preview, + _ => StoryTab::Generated, + }; + app.add_hitbox( + Rect::new(tab_x, tab_row.y, width, 1), + ClickTarget::StoryTab { tab }, + ); + } + tab_x = tab_x.saturating_add(width + 1); + } match app.story_tab() { StoryTab::Edit => render_edit_tab(frame, content_area, app), @@ -53,7 +76,9 @@ pub fn render_generate_stories_modal(frame: &mut Frame, area: Rect, app: &mut Ap StoryTab::Generated => render_generated_tab(frame, content_area, app), } - render_status_row(frame, status_row, app); + if let Some(line) = status_line { + frame.render_widget(Paragraph::new(line), rows[rows.len() - 2]); + } frame.render_widget( Paragraph::new(Span::styled( @@ -150,7 +175,7 @@ fn render_preview_tab(frame: &mut Frame, area: Rect, app: &App) { ); } -fn render_generated_tab(frame: &mut Frame, area: Rect, app: &App) { +fn render_generated_tab(frame: &mut Frame, area: Rect, app: &mut App) { if app.generated_stories.is_empty() { frame.render_widget( Paragraph::new(Span::styled( @@ -174,8 +199,9 @@ fn render_generated_tab(frame: &mut Frame, area: Rect, app: &App) { let mut lines: Vec> = Vec::new(); for index in start..end { - let draft = &app.generated_stories[index]; + let draft = app.generated_stories[index].clone(); let is_selected_row = index == selected; + let row_start = lines.len() as u16; let mut title = draft.story.title.clone(); if app.story_focus == StoryModalFocus::Edit && is_selected_row { @@ -237,37 +263,54 @@ fn render_generated_tab(frame: &mut Frame, area: Rect, app: &App) { lines.push(Line::from(detail_spans)); } } + let row_height = (lines.len() as u16).saturating_sub(row_start).max(1); + if row_start < area.height { + app.add_hitbox( + Rect::new( + area.x, + area.y + row_start, + area.width, + row_height.min(area.height.saturating_sub(row_start)), + ), + ClickTarget::StoryDraft { index }, + ); + } } frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); } -fn render_status_row(frame: &mut Frame, area: Rect, app: &App) { - let line = if app.ai_job_in_flight { - Line::from(Span::styled( +fn status_line(app: &App) -> Option> { + if app.ai_job_in_flight { + return Some(Line::from(Span::styled( format!( "{} Generating stories…", theme::spinner_frame(app.loading_tick) ), Style::default().fg(theme::ACCENT), - )) - } else if app.story_create_in_flight { - Line::from(Span::styled( + ))); + } + if app.story_create_in_flight { + return Some(Line::from(Span::styled( format!( "{} Creating stories…", theme::spinner_frame(app.loading_tick) ), Style::default().fg(theme::ACCENT), - )) - } else if let Some(status) = &app.story_status { - Line::from(Span::styled( - status.clone(), - Style::default().fg(theme::TEXT_MUTED), - )) - } else { - Line::default() - }; - frame.render_widget(Paragraph::new(line), area); + ))); + } + if matches!( + app.status_kind, + crate::StatusKind::Error | crate::StatusKind::Warning + ) { + return app.story_status.as_ref().map(|status| { + Line::from(Span::styled( + status.clone(), + Style::default().fg(theme::status_color(app.status_kind)), + )) + }); + } + None } fn hint_for(app: &App) -> &'static str { @@ -276,11 +319,9 @@ fn hint_for(app: &App) -> &'static str { } match app.story_focus { StoryModalFocus::Source => { - "Ctrl+G generate. Ctrl+P preview. Tab list. Ctrl+S create selected. Esc close." - } - StoryModalFocus::List => { - "Up/Down select. Space toggle. Ctrl+A all. Enter edit title. Ctrl+S create." + "Ctrl+G generate. Ctrl+P preview. Tab list. Ctrl+S create. Esc close." } + StoryModalFocus::List => "Up/Down select. Space toggle. Ctrl+A all. Enter edit.", StoryModalFocus::Edit => { "Type title. Backspace delete. Enter done. Tab source. Ctrl+S create." }