From 14b56603b1570d34945499b9963ce553d25d1ef1 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 2 Jul 2026 17:23:34 +0000 Subject: [PATCH 1/4] feat: redesign TUI into themed, settings-driven productivity app Restructure the UI layer out of lib.rs into per-surface modules with a centralized dark theme, add persisted settings (no env vars required), async job feedback, search, confirm dialogs, and a help overlay. - theme.rs: single palette (#0B1117 bg, #7C5CFF accent, semantic success/warning/error/info), shared styles, spinner frames - settings.rs: settings.json in the OS config dir with validation and a form model; env vars still win; live reload after save (Ctrl+,) - ui/: chrome (topbar with brand/connection/search, status bar with spinner + colored status), board (column accents, label pills, empty states, appearance options), modals (issue editor, notes, palette), stories (Edit/Preview/Generated tabs), settings, help (?), confirm - lib.rs: state/logic only; StatusKind, busy tracking, search filter with filtered navigation, delete-with-confirm, story preview tabs - main.rs: hjkl navigation, / search routing, e edit, ? help, Ctrl+, settings, s sync, per-job busy labels, settings-file config fallback - jobs.rs: results now carry success/error status kinds - markdown: themed renderer with underlined links, italics, ordered lists; behavior parity with the previous parser 262 tests pass; cargo fmt clean; clippy warnings limited to pre-existing items in untouched code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJsFyUeN5xJiw9hQzjx3Gj --- README.md | 12 + REDESIGN_PLAN.md | 51 + src/jobs.rs | 52 +- src/lib.rs | 3101 ++++++++++------------------------------- src/main.rs | 358 ++++- src/settings.rs | 603 ++++++++ src/storage.rs | 7 +- src/theme.rs | 143 ++ src/ui/board.rs | 336 +++++ src/ui/chrome.rs | 308 ++++ src/ui/confirm.rs | 60 + src/ui/help.rs | 134 ++ src/ui/markdown.rs | 410 ++++++ src/ui/mod.rs | 1247 +++++++++++++++++ src/ui/modals.rs | 250 ++++ src/ui/settings_ui.rs | 184 +++ src/ui/stories.rs | 302 ++++ 17 files changed, 5167 insertions(+), 2391 deletions(-) create mode 100644 REDESIGN_PLAN.md create mode 100644 src/settings.rs create mode 100644 src/theme.rs create mode 100644 src/ui/board.rs create mode 100644 src/ui/chrome.rs create mode 100644 src/ui/confirm.rs create mode 100644 src/ui/help.rs create mode 100644 src/ui/markdown.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/modals.rs create mode 100644 src/ui/settings_ui.rs create mode 100644 src/ui/stories.rs diff --git a/README.md b/README.md index 642bd90..590c665 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ Lane is a Rust/Ratatui terminal UI for a personal GitLab-backed kanban board. +## Configuration + +Environment variables are optional. Press `Ctrl+,` inside the app to open +Settings (GitLab URL/project/token, AI provider, appearance, confirmations); +values persist as `settings.json` in the OS config directory +(`$XDG_CONFIG_HOME/lane-ratatui` or `~/.config/lane-ratatui`). Environment +variables (`LANE_GITLAB_URL`, `LANE_GITLAB_TOKEN`, `LANE_PERSONAL_PROJECT_ID`, +`LANE_LLM_*`) still work and take precedence over saved settings. + +Press `?` inside the app for the full keyboard reference (`/` search, +`n` new issue, `g` generate stories, `s` sync, `hjkl`/arrows to navigate). + ## npm Package `@ossrandom/lane` ships prebuilt binaries. Installing and running the package diff --git a/REDESIGN_PLAN.md b/REDESIGN_PLAN.md new file mode 100644 index 0000000..a99305b --- /dev/null +++ b/REDESIGN_PLAN.md @@ -0,0 +1,51 @@ +# Lane TUI Redesign — Plan & Status + +Target: premium dark productivity TUI (Linear/Raycast-style) per design mock. +Backend (gitlab/llm/jobs/sync/labels/sanitize/model) stays intact. + +Status legend: [ ] todo · [~] in progress · [x] done + +**STATUS: COMPLETE** — all phases landed; 262 tests green, `cargo fmt --check` clean, +clippy: only 5 pre-existing warnings in untouched code (sanitize.rs, jobs.rs). + +## Phase 1 — Theme + app modes + component structure +- [x] `src/theme.rs`: centralized palette (BG #0B1117, SURFACE #111827, elevated #162231, border #263445, accent #7C5CFF, semantic colors), style helpers, spinner frames +- [x] `src/lib.rs` rewrite: state + logic only; new overlays Settings/Help/Confirm; StatusKind; busy tracking; search filter; story tabs; delete+confirm; settings form wiring (+new logic tests) +- [x] `src/ui/mod.rs`: render dispatch, modal frame, buttons, shortcut bar done; ported render tests pending integration + +## Phase 2 — Settings persistence +- [x] `src/settings.rs` (subagent): Settings struct, JSON persistence in config dir, validation, SettingsForm model, apply_to_env merge (env wins) +- [x] `main.rs`: config loads env → settings.json fallback; live reload after save (config_dirty) +- [x] Missing config guides to Settings (Ctrl+,) + +## Phase 3 — Async status/toast/spinner +- [x] busy label per job in main loop (subagent); spinner + colored status in status bar (subagent) +- [x] jobs.rs: status kinds on results (success/error arms) + +## Phase 4 — Board polish +- [x] `src/ui/board.rs` (subagent): column accents, label pills, focus ring, empty states, appearance settings, search-filtered rendering +- [x] `src/ui/chrome.rs` (subagent): topbar (brand, connection dot, project@user, search box, buttons), context-aware footer + +## Phase 5 — Modal redesign +- [x] `src/ui/modals.rs` (subagent): issue editor + notes + palette on shared modal chrome +- [x] `src/ui/confirm.rs` (subagent): confirm dialog (destructive actions) +- [x] `src/ui/settings_ui.rs` (subagent): tabbed settings modal, masked secrets, inline validation, save feedback + +## Phase 6 — Markdown + Generate Stories +- [x] `src/ui/markdown.rs` (subagent): themed renderer, links underlined, italic, ordered lists, tests +- [x] `src/ui/stories.rs` (subagent): Edit/Preview/Generated tabs, scrolling source, spinner while generating, review/select/edit/create flow + +## Phase 7 — Help, keys, tests, lint +- [x] `src/ui/help.rs` (subagent): `?` overlay with grouped shortcuts +- [x] `main.rs` keys (subagent): h/j/k/l, `/` search, `e` edit, `?` help, Ctrl+, settings, s sync (r kept); search input routing; settings-file config fallback; live reload; busy labels +- [x] Port/update render tests to new theme; new tests: settings, search nav, confirm flow, story tabs, help +- [x] cargo fmt + cargo test + clippy clean +- [x] Commit, push, draft PR + +## Acceptance criteria mapping +- Env vars optional → Phase 2 +- Settings persist + guide → Phase 2 +- Async feedback everywhere → Phase 3 +- Markdown/story flow → Phase 6 +- Navigation/modals/theme → Phases 1/4/5/7 +- Backend intact → no changes beyond status kinds diff --git a/src/jobs.rs b/src/jobs.rs index 12b663f..942cfaf 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -832,7 +832,10 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { app.ai_job_in_flight = false; let count = stories.len(); app.set_generated_stories(stories); - app.story_status = Some(format!("Generated {count} stories")); + app.set_status( + crate::StatusKind::Success, + format!("Generated {count} stories"), + ); } } JobResult::Connected(Ok(state)) => { @@ -842,6 +845,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { app.open_assigned_set.clear(); app.has_synced = false; app.replace_issue_records(&state.issues); + app.status_kind = crate::StatusKind::Success; app.story_status = Some(format!( "Connected as {} on {}", app.connected_user @@ -855,6 +859,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { )); } JobResult::BootstrappedLabels(Ok(summary)) => { + app.status_kind = crate::StatusKind::Success; app.story_status = Some(format!( "Bootstrap: {} created, {} already present", summary.created.len(), @@ -870,7 +875,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { app.close_overlay(); } } - app.story_status = Some(format!("Created issue #{iid}")); + app.set_status(crate::StatusKind::Success, format!("Created issue #{iid}")); } JobResult::IssueUpdated(Ok(issue)) => { let iid = issue.iid; @@ -881,16 +886,19 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { app.close_overlay(); } } - app.story_status = Some(format!("Saved issue #{iid}")); + app.set_status(crate::StatusKind::Success, format!("Saved issue #{iid}")); } JobResult::IssueDeleted(Ok(iid)) => { app.remove_issue(iid); - app.story_status = Some(format!("Deleted issue #{iid}")); + app.set_status(crate::StatusKind::Success, format!("Deleted issue #{iid}")); } JobResult::NotesLoaded(Ok((iid, request_id, notes))) => { let count = notes.len(); if app.set_notes_for_issue(iid, request_id, notes) { - app.story_status = Some(format!("Loaded {count} notes for #{iid}")); + app.set_status( + crate::StatusKind::Success, + format!("Loaded {count} notes for #{iid}"), + ); } } JobResult::NoteCreated(Ok((iid, request_id, body, note))) => { @@ -907,7 +915,10 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { if matches_pending { app.note_submit_pending = None; } - app.story_status = Some(format!("Created note on #{iid}")); + app.set_status( + crate::StatusKind::Success, + format!("Created note on #{iid}"), + ); } } JobResult::NoteCreated(Err(err)) => { @@ -919,7 +930,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { }); if matches_pending { app.note_submit_pending = None; - app.story_status = Some(err.error); + app.set_status(crate::StatusKind::Error, err.error); } } JobResult::SourceSnapshotPulled(Ok(iid)) => { @@ -932,7 +943,10 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { request_id: app.notes_session_id, }); } - app.story_status = Some(format!("Pulled source snapshot into #{iid}")); + app.set_status( + crate::StatusKind::Success, + format!("Pulled source snapshot into #{iid}"), + ); } JobResult::GeneratedStories { generation_id, @@ -940,7 +954,10 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { } => { if app.ai_generation_id == generation_id { app.ai_job_in_flight = false; - app.story_status = Some(format!("Story generation failed: {err}")); + app.set_status( + crate::StatusKind::Error, + format!("Story generation failed: {err}"), + ); } } JobResult::ImprovedDescription { @@ -969,6 +986,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { if app.ai_generation_id == generation_id { app.ai_job_in_flight = false; app.editor_labels = labels; + app.status_kind = crate::StatusKind::Success; app.story_status = Some(format!( "Suggested labels: {}", app.editor_labels.join(", ") @@ -982,7 +1000,10 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { app.open_assigned_set = sync.open_assigned_set; app.has_synced = true; app.replace_issue_records(&sync.issues); - app.story_status = Some(format!("Forked {forked}, skipped {skipped}, total {total}")); + app.set_status( + crate::StatusKind::Success, + format!("Forked {forked}, skipped {skipped}, total {total}"), + ); } JobResult::CreatedStories(result) => { app.story_create_in_flight = false; @@ -990,6 +1011,11 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { let failed = result.failed; app.append_issue_records(&created); app.deselect_created_story_drafts(&created, &failed); + app.status_kind = if failed.is_empty() { + crate::StatusKind::Success + } else { + crate::StatusKind::Error + }; app.story_status = Some(match (created.len(), failed.len()) { (created_count, 0) => format!("Created {created_count} stories"), (0, 1) => failed[0].error.clone(), @@ -1001,7 +1027,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { } JobResult::IssueCreated(Err(err)) | JobResult::IssueUpdated(Err(err)) => { app.editor_submit_in_flight = false; - app.story_status = Some(err); + app.set_status(crate::StatusKind::Error, err); } JobResult::Synced(Err(err)) | JobResult::IssueDeleted(Err(err)) @@ -1009,7 +1035,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { | JobResult::BootstrappedLabels(Err(err)) | JobResult::NotesLoaded(Err(err)) | JobResult::SourceSnapshotPulled(Err(err)) => { - app.story_status = Some(err); + app.set_status(crate::StatusKind::Error, err); } JobResult::ImprovedDescription { generation_id, @@ -1025,7 +1051,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { } => { if app.ai_generation_id == generation_id { app.ai_job_in_flight = false; - app.story_status = Some(err); + app.set_status(crate::StatusKind::Error, err); } } } diff --git a/src/lib.rs b/src/lib.rs index 048967f..217d876 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,10 @@ -use ratatui::{ - prelude::*, - symbols::border, - widgets::{Block, BorderType, Borders, Clear, Gauge, Paragraph, Wrap}, -}; +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::sanitize::sanitize_for_gitlab; +use crate::settings::{Settings, SettingsForm}; use crate::storage::FileStore; use crate::sync::SourceIssueKey; @@ -18,25 +15,19 @@ pub mod labels; pub mod llm; pub mod model; pub mod sanitize; +pub mod settings; pub mod storage; pub mod sync; +pub mod theme; +pub mod ui; -const INK: Color = Color::Black; -const SURFACE: Color = Color::Rgb(3, 7, 18); -const SURFACE_RAISED: Color = Color::Rgb(39, 39, 42); -const SURFACE_SHADOW: Color = Color::Rgb(0, 0, 0); -const AMBER: Color = Color::Rgb(245, 158, 11); -const GREEN: Color = Color::Rgb(34, 197, 94); -const SLATE: Color = Color::Rgb(100, 116, 139); -const SLATE_DARK: Color = Color::Rgb(51, 65, 85); -const TEXT: Color = Color::Rgb(248, 250, 252); -const TEXT_MUTED: Color = Color::Rgb(148, 163, 184); -const RED: Color = Color::Rgb(220, 38, 38); +pub use ui::render; pub const MAX_TITLE_CHARS: usize = 120; pub const MAX_DESCRIPTION_CHARS: usize = 4_000; pub const MAX_STORY_SOURCE_CHARS: usize = 8_000; pub const MAX_NOTE_CHARS: usize = MAX_DESCRIPTION_CHARS; +pub const MAX_SEARCH_CHARS: usize = 80; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LaneState { @@ -73,6 +64,9 @@ pub enum Overlay { NewIssue, GenerateStories, Palette, + Settings, + Help, + Confirm, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -81,6 +75,33 @@ pub enum EditorField { Description, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StatusKind { + Info, + Success, + Warning, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoryTab { + Edit, + Preview, + Generated, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfirmAction { + DeleteIssue { iid: u64 }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfirmRequest { + pub title: String, + pub body: String, + pub action: ConfirmAction, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum ClickTarget { Issue { column: usize, index: usize }, @@ -94,6 +115,7 @@ pub enum ClickTarget { Save, Cancel, CreateSelectedStories, + SettingsSection { index: usize }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -106,6 +128,9 @@ pub enum PaletteAction { Connect, BootstrapLabels, GenerateStories, + DeleteIssue, + OpenSettings, + OpenHelp, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -171,6 +196,18 @@ pub struct App { pub story_focus: StoryModalFocus, pub story_selected: usize, pub story_status: Option, + pub story_preview: bool, + pub story_source_scroll: usize, + pub status_kind: StatusKind, + pub busy: Option, + pub search_active: bool, + pub search_query: String, + pub settings: Settings, + pub settings_form: Option, + pub settings_root: std::path::PathBuf, + pub confirm: Option, + pub config_dirty: bool, + pub help_scroll: usize, pub connected_user: Option, pub connected_project: Option, pub project_labels: Vec, @@ -352,6 +389,18 @@ impl App { story_focus: StoryModalFocus::Source, story_selected: 0, story_status: None, + story_preview: false, + story_source_scroll: 0, + status_kind: StatusKind::Info, + busy: None, + search_active: false, + search_query: String::new(), + settings: Settings::default(), + settings_form: None, + settings_root: crate::storage::default_root(), + confirm: None, + config_dirty: false, + help_scroll: 0, connected_user: None, connected_project: None, project_labels: Vec::new(), @@ -378,25 +427,46 @@ impl App { 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()); + } + pub fn move_column_right(&mut self) { self.active_column = (self.active_column + 1).min(self.columns.len().saturating_sub(1)); + self.align_column_selection(self.active_column); } pub fn move_column_left(&mut self) { self.active_column = self.active_column.saturating_sub(1); + self.align_column_selection(self.active_column); } pub fn move_issue_down(&mut self) { - let len = self.columns[self.active_column].len(); - if len == 0 { + let rows = self.visible_rows(self.active_column); + if rows.is_empty() { return; } - self.selected[self.active_column] = (self.selected[self.active_column] + 1).min(len - 1); + let current = self.selected[self.active_column]; + let next = match rows.iter().position(|&index| index == current) { + Some(pos) => rows[(pos + 1).min(rows.len() - 1)], + None => rows[0], + }; + self.selected[self.active_column] = next; self.ensure_selected_visible(self.active_column, 4); } pub fn move_issue_up(&mut self) { - self.selected[self.active_column] = self.selected[self.active_column].saturating_sub(1); + let rows = self.visible_rows(self.active_column); + if rows.is_empty() { + return; + } + let current = self.selected[self.active_column]; + let next = match rows.iter().position(|&index| index == current) { + Some(pos) => rows[pos.saturating_sub(1)], + None => rows[0], + }; + self.selected[self.active_column] = next; self.ensure_selected_visible(self.active_column, 4); } @@ -412,6 +482,85 @@ impl App { } } + // --- Search --- + + pub fn start_search(&mut self) { + if self.overlay.is_none() { + self.search_active = true; + } + } + + pub fn end_search(&mut self, keep_query: bool) { + self.search_active = false; + if !keep_query { + self.search_query.clear(); + } + } + + pub fn input_search_char(&mut self, ch: char) { + if self.search_active && !ch.is_control() { + push_bounded_char(&mut self.search_query, ch, MAX_SEARCH_CHARS); + self.align_selection_to_search(); + } + } + + pub fn backspace_search(&mut self) { + if self.search_active { + self.search_query.pop(); + self.align_selection_to_search(); + } + } + + pub fn issue_matches_search(&self, issue: &Issue) -> bool { + if self.search_query.is_empty() { + return true; + } + let query = self.search_query.to_lowercase(); + issue.title.to_lowercase().contains(&query) + || format!("#{}", issue.iid).contains(&query) + || issue + .labels + .iter() + .any(|label| label.to_lowercase().contains(&query)) + } + + pub fn visible_rows(&self, column: usize) -> Vec { + let Some(issues) = self.columns.get(column) else { + return Vec::new(); + }; + issues + .iter() + .enumerate() + .filter(|(_, issue)| self.issue_matches_search(issue)) + .map(|(index, _)| index) + .collect() + } + + pub fn search_match_count(&self) -> usize { + (0..self.columns.len()) + .map(|column| self.visible_rows(column).len()) + .sum() + } + + fn align_selection_to_search(&mut self) { + for column in 0..self.columns.len() { + self.align_column_selection(column); + } + self.ensure_selected_visible(self.active_column, 4); + } + + fn align_column_selection(&mut self, column: usize) { + let rows = self.visible_rows(column); + if rows.is_empty() { + return; + } + if !rows.contains(&self.selected[column]) { + self.selected[column] = rows[0]; + } + } + + // --- Overlays --- + pub fn open_details(&mut self) { let Some(issue) = self.selected_issue_opt().cloned() else { self.overlay = None; @@ -443,7 +592,7 @@ impl App { iid, request_id: self.notes_session_id, }); - self.story_status = Some(format!("Loading notes for #{iid}")); + self.set_status(StatusKind::Info, format!("Loading notes for #{iid}")); } } @@ -466,7 +615,10 @@ impl App { source_project_path: source.project_id.to_string(), source_iid: source.issue_iid, }); - self.story_status = Some(format!("Pull source snapshot pending for #{local_iid}")); + self.set_status( + StatusKind::Info, + format!("Pull source snapshot pending for #{local_iid}"), + ); } pub fn open_palette(&mut self) { @@ -491,14 +643,120 @@ impl App { self.story_focus = StoryModalFocus::Source; self.story_selected = 0; self.story_status = None; + self.story_preview = false; + self.story_source_scroll = 0; self.overlay = Some(Overlay::GenerateStories); } + pub fn open_settings(&mut self) { + self.settings_form = Some(SettingsForm::new(self.settings.clone())); + self.overlay = Some(Overlay::Settings); + } + + pub fn open_help(&mut self) { + self.help_scroll = 0; + self.overlay = Some(Overlay::Help); + } + + pub fn scroll_help(&mut self, delta: i16) { + if delta > 0 { + self.help_scroll = (self.help_scroll + delta as usize).min(60); + } else { + self.help_scroll = self + .help_scroll + .saturating_sub(delta.unsigned_abs() as usize); + } + } + pub fn close_overlay(&mut self) { self.cancel_ai_request(false); + self.settings_form = None; + self.confirm = None; + self.overlay = None; + } + + // --- Confirm dialogs --- + + pub fn request_delete_issue(&mut self) { + let Some(issue) = self.selected_issue_opt() else { + return; + }; + let iid = issue.iid; + let title = issue.title.clone(); + if self.settings.advanced.confirm_destructive { + self.confirm = Some(ConfirmRequest { + title: format!("Delete issue #{iid}?"), + body: format!("\"{title}\" will be permanently deleted. This cannot be undone."), + action: ConfirmAction::DeleteIssue { iid }, + }); + self.overlay = Some(Overlay::Confirm); + } else { + self.queue_delete_issue(iid); + } + } + + pub fn confirm_accept(&mut self) { + let Some(request) = self.confirm.take() else { + self.overlay = None; + return; + }; + self.overlay = None; + match request.action { + ConfirmAction::DeleteIssue { iid } => self.queue_delete_issue(iid), + } + } + + pub fn confirm_cancel(&mut self) { + self.confirm = None; self.overlay = None; + self.set_status(StatusKind::Info, "Cancelled"); + } + + fn queue_delete_issue(&mut self, iid: u64) { + if self.connected_project.is_some() { + self.pending_job = Some(crate::jobs::JobCommand::DeleteIssue { iid }); + self.set_status(StatusKind::Info, format!("Delete issue #{iid} pending")); + } else { + self.remove_issue(iid); + self.set_status(StatusKind::Success, format!("Deleted issue #{iid}")); + } + } + + // --- Settings --- + + pub fn save_settings(&mut self) { + let Some(mut form) = self.settings_form.take() else { + return; + }; + let errors = crate::settings::validate(&form.draft); + if !errors.is_empty() { + form.errors = errors; + form.feedback = Some(Err("Fix the highlighted fields".into())); + self.set_status( + StatusKind::Error, + "Settings not saved: fix highlighted fields", + ); + self.settings_form = Some(form); + return; + } + form.errors.clear(); + match form.draft.save_to(&self.settings_root) { + Ok(()) => { + self.settings = form.draft.clone(); + form.feedback = Some(Ok("Settings saved".into())); + self.config_dirty = true; + self.set_status(StatusKind::Success, "Settings saved"); + } + Err(err) => { + form.feedback = Some(Err(format!("Save failed: {err}"))); + self.set_status(StatusKind::Error, format!("Settings save failed: {err}")); + } + } + self.settings_form = Some(form); } + // --- Editor input --- + pub fn input_editor_char(&mut self, ch: char) { if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) && !ch.is_control() @@ -539,7 +797,7 @@ impl App { } fn editor_description_scroll_max(&self) -> usize { - description_lines( + crate::ui::markdown::description_lines( &self.editor_description, self.editor_field == EditorField::Description, self.editor_preview, @@ -551,6 +809,7 @@ impl App { pub fn input_story_source_char(&mut self, ch: char) { if self.overlay == Some(Overlay::GenerateStories) && self.story_focus == StoryModalFocus::Source + && !self.story_preview && !ch.is_control() { push_bounded_char(&mut self.story_source, ch, MAX_STORY_SOURCE_CHARS); @@ -560,11 +819,47 @@ impl App { pub fn input_story_source_newline(&mut self) { if self.overlay == Some(Overlay::GenerateStories) && self.story_focus == StoryModalFocus::Source + && !self.story_preview { push_bounded_char(&mut self.story_source, '\n', MAX_STORY_SOURCE_CHARS); } } + pub fn toggle_story_preview(&mut self) { + if self.overlay == Some(Overlay::GenerateStories) { + self.story_preview = !self.story_preview; + if self.story_preview { + self.story_focus = StoryModalFocus::Source; + } + self.story_source_scroll = 0; + } + } + + pub fn story_tab(&self) -> StoryTab { + if self.story_preview { + StoryTab::Preview + } else { + match self.story_focus { + StoryModalFocus::Source => StoryTab::Edit, + StoryModalFocus::List | StoryModalFocus::Edit => StoryTab::Generated, + } + } + } + + pub fn scroll_story_source(&mut self, delta: i16) { + if self.overlay != Some(Overlay::GenerateStories) { + return; + } + let max_scroll = self.story_source.lines().count().saturating_sub(1); + if delta > 0 { + self.story_source_scroll = (self.story_source_scroll + delta as usize).min(max_scroll); + } else { + self.story_source_scroll = self + .story_source_scroll + .saturating_sub(delta.unsigned_abs() as usize); + } + } + pub fn input_note_char(&mut self, ch: char) { if self.overlay == Some(Overlay::Notes) && !ch.is_control() { push_bounded_char(&mut self.note_input, ch, MAX_NOTE_CHARS); @@ -577,6 +872,8 @@ impl App { } } + // --- Story generation --- + pub fn set_generated_stories(&mut self, stories: Vec) { self.generated_stories = stories .into_iter() @@ -606,7 +903,7 @@ impl App { available_labels, generation_id, }); - self.story_status = Some("Generating stories".to_string()); + self.set_status(StatusKind::Info, "Generating stories"); } pub fn request_improve_description(&mut self) { @@ -619,7 +916,7 @@ impl App { description, generation_id, }); - self.story_status = Some("AI improve pending".to_string()); + self.set_status(StatusKind::Info, "AI improve pending"); } pub fn request_generate_criteria(&mut self) { @@ -632,7 +929,7 @@ impl App { description, generation_id, }); - self.story_status = Some("AI criteria pending".to_string()); + self.set_status(StatusKind::Info, "AI criteria pending"); } pub fn request_suggest_labels(&mut self) { @@ -653,7 +950,7 @@ impl App { available, generation_id, }); - self.story_status = Some("AI labels pending".to_string()); + self.set_status(StatusKind::Info, "AI labels pending"); } pub fn cancel_ai_request(&mut self, show_status: bool) { @@ -679,18 +976,18 @@ impl App { self.pending_job = None; } if show_status { - self.story_status = Some("AI request cancelled".to_string()); + self.set_status(StatusKind::Warning, "AI request cancelled"); } } pub fn request_sync_all(&mut self) { self.pending_job = Some(crate::jobs::JobCommand::SyncAll); - self.story_status = Some("Sync all pending".to_string()); + self.set_status(StatusKind::Info, "Sync all pending"); } pub fn request_connect(&mut self) { self.pending_job = Some(crate::jobs::JobCommand::Connect); - self.story_status = Some("Connecting to GitLab".to_string()); + self.set_status(StatusKind::Info, "Connecting to GitLab"); } pub fn is_connecting(&self) -> bool { @@ -698,7 +995,7 @@ impl App { } pub fn tick_loading(&mut self) { - if self.is_connecting() { + if self.busy.is_some() || self.is_connecting() { self.loading_tick = self.loading_tick.wrapping_add(1); } else { self.loading_tick = 0; @@ -707,9 +1004,11 @@ impl App { pub fn request_bootstrap_labels(&mut self) { self.pending_job = Some(crate::jobs::JobCommand::BootstrapLabels); - self.story_status = Some("Bootstrapping labels".to_string()); + self.set_status(StatusKind::Info, "Bootstrapping labels"); } + // --- Board data --- + pub fn replace_issue_records(&mut self, records: &[crate::model::IssueRecord]) { let selected_iid = self.selected_issue_opt().map(|issue| issue.iid); self.columns = vec![Vec::new(), Vec::new(), Vec::new()]; @@ -823,6 +1122,7 @@ impl App { if self.overlay != Some(Overlay::GenerateStories) { return; } + self.story_preview = false; self.story_focus = match self.story_focus { StoryModalFocus::Source if self.generated_stories.is_empty() => StoryModalFocus::Source, StoryModalFocus::Source => StoryModalFocus::List, @@ -906,7 +1206,9 @@ impl App { } match self.story_focus { StoryModalFocus::Source => { - self.story_source.pop(); + if !self.story_preview { + self.story_source.pop(); + } } StoryModalFocus::Edit => { if let Some(draft) = self.generated_stories.get_mut(self.story_selected) { @@ -940,7 +1242,7 @@ impl App { .map(|label| label.name.clone()) .collect(), }); - self.story_status = Some("Create selected stories pending".to_string()); + self.set_status(StatusKind::Info, "Create selected stories pending"); } pub fn deselect_created_story_drafts( @@ -1034,7 +1336,7 @@ impl App { labels: self.editor_labels.clone(), current_labels, }); - self.story_status = Some("Update issue pending".to_string()); + self.set_status(StatusKind::Info, "Update issue pending"); } } else { let column = self.active_column; @@ -1064,7 +1366,7 @@ impl App { description, labels: self.editor_labels.clone(), }); - self.story_status = Some("Create issue pending".to_string()); + self.set_status(StatusKind::Info, "Create issue pending"); } else { let iid = self.next_iid(); self.columns[0].push(Issue { @@ -1104,12 +1406,19 @@ impl App { request_id: self.notes_session_id, }); self.note_submit_pending = Some((self.notes_session_id, body)); - self.story_status = Some(format!("Create note pending for #{iid}")); + self.set_status( + StatusKind::Info, + format!("Create note pending for #{iid}"), + ); } } } } - Some(Overlay::Palette | Overlay::GenerateStories) => self.close_overlay(), + Some(Overlay::Settings) => self.save_settings(), + Some(Overlay::Confirm) => self.confirm_accept(), + Some(Overlay::Palette | Overlay::GenerateStories | Overlay::Help) => { + self.close_overlay() + } None => {} } } @@ -1130,7 +1439,7 @@ impl App { flag: crate::model::Flag::Blocked, on: !issue.blocked, }); - self.story_status = Some("Blocked toggle pending".to_string()); + self.set_status(StatusKind::Info, "Blocked toggle pending"); } } else { let column = self.active_column; @@ -1150,6 +1459,8 @@ impl App { } } + // --- Mouse --- + pub fn click(&mut self, target: ClickTarget) { match target { ClickTarget::Issue { column, index } => { @@ -1174,10 +1485,22 @@ impl App { self.execute_palette_selection(); } ClickTarget::Save => self.save_overlay(), - ClickTarget::Cancel => self.close_overlay(), + ClickTarget::Cancel => { + if self.overlay == Some(Overlay::Confirm) { + self.confirm_cancel(); + } else { + self.close_overlay(); + } + } ClickTarget::CreateSelectedStories => { self.request_create_selected_stories(); } + ClickTarget::SettingsSection { index } => { + if let Some(form) = self.settings_form.as_mut() { + form.section = index.min(crate::settings::SECTION_TITLES.len() - 1); + form.focused = 0; + } + } } } @@ -1259,9 +1582,20 @@ impl App { } pub fn wheel_at(&mut self, x: u16, y: u16, delta: i16) { - if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) { - self.scroll_editor_description(delta); - return; + match self.overlay { + Some(Overlay::Details | Overlay::NewIssue) => { + self.scroll_editor_description(delta); + return; + } + Some(Overlay::Help) => { + self.scroll_help(delta); + return; + } + Some(Overlay::GenerateStories) => { + self.scroll_story_source(delta); + return; + } + _ => {} } if let Some(hitbox) = self .hitboxes @@ -1320,7 +1654,7 @@ impl App { true } - fn selected_issue_opt(&self) -> Option<&Issue> { + pub fn selected_issue_opt(&self) -> Option<&Issue> { let selected = *self.selected.get(self.active_column)?; self.columns.get(self.active_column)?.get(selected) } @@ -1362,7 +1696,7 @@ impl App { }, PaletteCommand { name: "Sync all", - shortcut: "r", + shortcut: "s", action: PaletteAction::SyncAll, }, PaletteCommand { @@ -1380,6 +1714,21 @@ impl App { shortcut: "g", action: PaletteAction::GenerateStories, }, + PaletteCommand { + name: "Delete issue", + shortcut: "del", + action: PaletteAction::DeleteIssue, + }, + PaletteCommand { + name: "Settings", + shortcut: "ctrl+,", + action: PaletteAction::OpenSettings, + }, + PaletteCommand { + name: "Help", + shortcut: "?", + action: PaletteAction::OpenHelp, + }, ]; let query = self.palette_query.to_lowercase(); if query.is_empty() { @@ -1420,6 +1769,12 @@ impl App { self.close_overlay(); } PaletteAction::GenerateStories => self.open_generate_stories(), + PaletteAction::DeleteIssue => { + self.close_overlay(); + self.request_delete_issue(); + } + PaletteAction::OpenSettings => self.open_settings(), + PaletteAction::OpenHelp => self.open_help(), } } @@ -1475,7 +1830,7 @@ impl App { from, to, }); - self.story_status = Some("Move pending".to_string()); + self.set_status(StatusKind::Info, "Move pending"); return; } @@ -1544,7 +1899,7 @@ impl App { }; } - fn next_iid(&self) -> u64 { + pub(crate) fn next_iid(&self) -> u64 { self.columns .iter() .flatten() @@ -1633,10 +1988,18 @@ fn lane_state_from_board(state: crate::model::BoardState) -> LaneState { } } -fn contains(rect: Rect, x: u16, y: u16) -> bool { +pub(crate) fn contains(rect: Rect, x: u16, y: u16) -> bool { x >= rect.x && y >= rect.y && x < rect.x + rect.width && y < rect.y + rect.height } +pub(crate) fn state_for(index: usize) -> LaneState { + match index { + 0 => LaneState::Todo, + 1 => LaneState::Doing, + _ => LaneState::Done, + } +} + fn sidecar_column_key(column: usize) -> &'static str { match column { 0 => "todo", @@ -1645,1574 +2008,107 @@ fn sidecar_column_key(column: usize) -> &'static str { } } -pub fn render(frame: &mut Frame, app: &mut App) { - app.clear_hitboxes(); - let root = frame.area(); - let shell = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(10), - Constraint::Length(3), - ]) - .split(root); - - render_topbar(frame, shell[0], app); - render_board(frame, shell[1], app); - render_status(frame, shell[2], app); - - if let Some(overlay) = app.overlay.clone() { - render_locked_viewport(frame, root); - app.clear_hitboxes(); - match overlay { - Overlay::Palette => render_palette(frame, root, app), - Overlay::Details => render_issue_modal(frame, root, app, false), - Overlay::Notes => render_notes_modal(frame, root, app), - Overlay::NewIssue => render_issue_modal(frame, root, app, true), - Overlay::GenerateStories => render_generate_stories_modal(frame, root, app), - } - } else { - render_drag_ghost(frame, root, app); - } -} - -fn render_locked_viewport(frame: &mut Frame, area: Rect) { - let block = Block::new() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(Color::Rgb(51, 65, 85))) - .style(Style::default().fg(Color::Rgb(100, 116, 139))); - frame.render_widget(block, area); -} +#[cfg(test)] +mod tests { + use super::*; + use crate::model::BoardState; + use crate::storage::FileStore; -fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { - let block = Block::new() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(AMBER)) - .style(Style::default().bg(INK)); - let inner = block.inner(area); - frame.render_widget(block, area); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - "Lane", - Style::default().fg(TEXT).add_modifier(Modifier::BOLD), - ))) - .alignment(Alignment::Center), - inner, - ); - - if let Some(summary) = topbar_summary(app) { - let summary_width = inner.width.saturating_div(3).min(40); - if summary_width > 0 { - frame.render_widget( - Paragraph::new(summary).style(Style::default().fg(TEXT_MUTED)), - Rect::new(inner.x, inner.y, summary_width, 1), - ); + fn issue_record(iid: u64, title: &str, state: BoardState) -> crate::model::IssueRecord { + crate::model::IssueRecord { + id: iid, + iid, + project_id: 42, + title: title.into(), + description: String::new(), + state, + labels: Vec::new(), + due_date: None, + weight: None, + web_url: format!("https://gitlab.example.com/me/lane/-/issues/{iid}"), + reference: format!("me/lane#{iid}"), + flags: Vec::new(), + origin: crate::model::Origin::Side, + source: None, + note_count: None, + updated_at: None, } } - let button_y = inner.y; - let buttons = [ - ("Connect", 11, false, ClickTarget::Connect), - ("Sync", 8, false, ClickTarget::SyncAll), - ("New Issue", 12, false, ClickTarget::NewIssue), - ("Generate Stories", 18, true, ClickTarget::GenerateStories), - ]; - let total_width = buttons.iter().map(|(_, width, _, _)| *width).sum::() - + (buttons.len().saturating_sub(1) as u16 * 2); - if inner.width > total_width { - let mut x = inner.x + inner.width.saturating_sub(total_width); - for (label, width, primary, target) in buttons { - let rect = Rect::new(x, button_y, width, 1); - render_button(frame, rect, label, primary); - app.add_hitbox(rect, target); - x = x.saturating_add(width + 2); - } + fn unique_temp_root(tag: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!("lane-test-{tag}-{}-{nanos}", std::process::id())) } -} - -fn render_board(frame: &mut Frame, area: Rect, app: &mut App) { - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(34), - Constraint::Percentage(33), - Constraint::Percentage(33), - ]) - .split(area); - for column_index in 0..columns.len().min(app.columns.len()) { - render_column(frame, columns[column_index], app, column_index); - } -} + #[test] + fn generate_stories_modal_edits_source_markdown_and_holds_drafts() { + let mut app = App::demo(); -fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usize) { - let state = state_for(column_index); - let active = app.active_column == column_index; - let drop_active = drag_over_column(app, column_index); - let color = state_color(state); - let block = Block::new() - .title(format!( - " {} {:02} ", - state.title(), - app.columns[column_index].len() - )) - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(if drop_active { GREEN } else { color })) - .style(Style::default().bg(INK)); - let inner = block.inner(area); - frame.render_widget(block, area); - app.add_hitbox( - inner, - ClickTarget::Column { - column: column_index, - }, - ); - - let card_height = 4; - let gap = 1; - let stride = card_height + gap; - let visible = (inner.height / stride.max(1)).max(1) as usize; - let scroll = app.scroll[column_index].min(app.columns[column_index].len().saturating_sub(1)); - let selected = app.selected[column_index]; - let issues: Vec = app.columns[column_index] - .iter() - .skip(scroll) - .take(visible + 1) - .cloned() - .collect(); - - for (visible_index, issue) in issues.iter().enumerate() { - let absolute_index = scroll + visible_index; - let y = inner.y + visible_index as u16 * stride; - if y + card_height > inner.y + inner.height { - break; - } - 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 && drag.source_column == column_index && drag.source_index == absolute_index - }) { - render_lift_placeholder(frame, rect); - continue; + app.open_generate_stories(); + for ch in "# ADR\nUse GitLab sync".chars() { + if ch == '\n' { + app.input_story_source_newline(); + } else { + app.input_story_source_char(ch); + } } - let is_selected = active && selected == absolute_index; - render_card(frame, rect, issue, is_selected, color); - app.add_hitbox( - rect, - ClickTarget::Issue { - column: column_index, - index: absolute_index, - }, - ); - } - - if drop_active && inner.width > 4 { - render_drop_target( - frame, - Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(3), 1), - ); - } -} + app.set_generated_stories(vec![crate::model::GeneratedStory { + title: "Sync assigned issues".into(), + description: "Pull and fork issues".into(), + acceptance_criteria: vec!["Sync button creates issues".into()], + labels: vec!["backend".into()], + priority: Some("P1".into()), + size: Some("M".into()), + }]); -fn render_card(frame: &mut Frame, area: Rect, issue: &Issue, selected: bool, accent: Color) { - let border_color = if selected { accent } else { SLATE_DARK }; - let bg = if selected { SURFACE_RAISED } else { SURFACE }; - let block = Block::new() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(border_color)) - .style(Style::default().bg(bg)); - let inner = block.inner(area); - frame.render_widget(block, area); - if area.width > 3 { - frame.render_widget( - Paragraph::new(" ").style(Style::default().bg(if issue.blocked { - RED - } else { - accent - })), - Rect::new(area.x + 1, area.y + 1, 1, area.height.saturating_sub(2)), - ); + assert_eq!(app.story_source, "# ADR\nUse GitLab sync"); + assert_eq!(app.generated_stories.len(), 1); + assert!(app.generated_stories[0].selected); } - let labels = issue.labels.join("/"); - let text = Text::from(vec![ - Line::from(vec![ - Span::styled( - format!("#{} ", issue.iid), - Style::default().fg(accent).add_modifier(Modifier::BOLD), - ), - Span::styled( - issue.title.as_str(), - Style::default().fg(TEXT).add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![Span::styled( - labels.to_uppercase(), - chip_style(Color::Rgb(203, 213, 225)), - )]), - Line::from(vec![ - Span::styled("STATE ", Style::default().fg(Color::Rgb(100, 116, 139))), - Span::styled(issue.state.title(), Style::default().fg(accent)), - ]), - ]); - let content = Rect::new( - inner.x + 2, - inner.y, - inner.width.saturating_sub(2), - inner.height, - ); - frame.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), content); - if issue.blocked && area.width > 14 { - frame.render_widget( - Paragraph::new(" BLOCKED ") - .alignment(Alignment::Center) - .style(blocked_style()), - Rect::new(area.x + area.width.saturating_sub(10), inner.y, 9, 1), - ); - } -} + #[test] + fn connected_reorder_persists_sidecar_order() { + let root = unique_temp_root("order"); + let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); + 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()); -fn render_drop_target(frame: &mut Frame, area: Rect) { - frame.render_widget( - Paragraph::new(" DROP TARGET ") - .alignment(Alignment::Center) - .style( - Style::default() - .fg(Color::Black) - .bg(Color::Rgb(34, 197, 94)) - .add_modifier(Modifier::BOLD), - ), - area, - ); -} + 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); -fn render_lift_placeholder(frame: &mut Frame, area: Rect) { - let block = Block::new() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(Color::Rgb(71, 85, 105))) - .style(Style::default().bg(INK)); - frame.render_widget(block, area); - if area.width > 4 && area.height > 2 { - frame.render_widget( - Paragraph::new("LIFTED") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Rgb(100, 116, 139))), - Rect::new( - area.x + 1, - area.y + area.height / 2, - area.width.saturating_sub(2), - 1, - ), + 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); } -} -fn render_drag_ghost(frame: &mut Frame, area: Rect, app: &App) { - let Some(drag) = app.drag.as_ref().filter(|drag| drag.moved) else { - return; - }; - let Some(issue) = app - .columns - .get(drag.source_column) - .and_then(|issues| issues.get(drag.source_index)) - else { - return; - }; - - let width = 34.min(area.width.saturating_sub(2)); - let height = 5.min(area.height.saturating_sub(2)); - if width < 4 || height < 3 { - return; - } - let max_x = area.x + area.width.saturating_sub(width + 1); - let max_y = area.y + area.height.saturating_sub(height + 1); - let x = drag.current_x.saturating_sub(6).clamp(area.x + 1, max_x); - let y = drag.current_y.saturating_sub(2).clamp(area.y + 1, max_y); - let rect = Rect::new(x, y, width, height); - let accent = state_color(issue.state); - let block = Block::new() - .title(format!(" DRAGGING #{} ", issue.iid)) - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(Color::Rgb(34, 197, 94))) - .style(Style::default().bg(Color::Rgb(15, 23, 42))); - let inner = block.inner(rect); - frame.render_widget(Clear, rect); - frame.render_widget(block, rect); - frame.render_widget( - Paragraph::new(Text::from(vec![ - Line::from(vec![Span::styled( - issue.title.to_uppercase(), - Style::default() - .fg(Color::Rgb(248, 250, 252)) - .add_modifier(Modifier::BOLD), - )]), - Line::from(vec![ - Span::styled("FROM ", Style::default().fg(Color::Rgb(100, 116, 139))), - Span::styled(issue.state.title(), Style::default().fg(accent)), - Span::raw(" "), - Span::styled("DROP TO MOVE", Style::default().fg(Color::Rgb(34, 197, 94))), - ]), - ])), - inner, - ); -} - -fn drag_over_column(app: &App, column: usize) -> bool { - matches!( - app.drag.as_ref().and_then(|drag| drag.over), - Some(DropTarget::Column { column: over_column }) if over_column == column - ) || matches!( - app.drag.as_ref().and_then(|drag| drag.over), - Some(DropTarget::Issue { column: over_column, .. }) if over_column == column - ) -} - -fn render_status(frame: &mut Frame, area: Rect, app: &App) { - let shortcut_items = footer_shortcuts(app); - let block = Block::new() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(Color::Rgb(245, 158, 11))) - .style(Style::default().bg(INK)); - let inner = block.inner(area); - frame.render_widget(block, area); - - if let Some(status) = app.story_status.as_deref() { - let status_shortcuts = status_shortcuts(app, inner.width); - let shortcut_width = inner.width.min(shortcut_line_width(&status_shortcuts)); - let shortcut_gap = u16::from(shortcut_width > 0); - let left_width = inner.width.saturating_sub(shortcut_width + shortcut_gap); - if app.is_connecting() && left_width > 36 { - let status_width = status.chars().count().min(left_width as usize) as u16; - frame.render_widget( - Paragraph::new(status).style(Style::default().fg(TEXT_MUTED)), - Rect::new(inner.x, inner.y, status_width, 1), - ); - let gauge_width = left_width.saturating_sub(status_width + 1).min(24); - if gauge_width > 0 { - render_connecting_gauge( - frame, - Rect::new(inner.x + status_width + 1, inner.y, gauge_width, 1), - app, - ); - } - } else if left_width > 0 { - frame.render_widget( - Paragraph::new(status).style(Style::default().fg(TEXT_MUTED)), - Rect::new(inner.x, inner.y, left_width, 1), - ); - } - if shortcut_width > 0 { - frame.render_widget( - Paragraph::new(shortcut_line(&status_shortcuts)).alignment(Alignment::Right), - Rect::new( - inner.x + inner.width.saturating_sub(shortcut_width), - inner.y, - shortcut_width, - 1, - ), - ); - } - return; - } - - frame.render_widget( - Paragraph::new(shortcut_line(&shortcut_items)).alignment(Alignment::Center), - inner, - ); -} - -fn render_connecting_gauge(frame: &mut Frame, area: Rect, app: &App) { - let ratio = f64::from((app.loading_tick % 16) + 1) / 16.0; - let gauge = Gauge::default() - .ratio(ratio) - .label("") - .style(Style::default().bg(INK)) - .gauge_style( - Style::default() - .fg(Color::Black) - .bg(AMBER) - .add_modifier(Modifier::BOLD), - ); - frame.render_widget(gauge, area); -} - -fn footer_shortcuts(app: &App) -> Vec<(&'static str, &'static str)> { - if app.overlay.is_some() { - vec![("Esc", "Close"), ("q", "Quit")] - } else { - vec![ - ("c", "Connect"), - ("r", "Sync"), - ("n", "New"), - ("Enter", "Open"), - ("b", "Block"), - ("Arrows", "Move"), - ("q", "Quit"), - ] - } -} - -fn status_shortcuts(app: &App, width: u16) -> Vec<(&'static str, &'static str)> { - if app.overlay.is_some() { - let overlay_shortcuts = vec![("Esc", "Close"), ("q", "Quit")]; - if shortcut_line_width(&overlay_shortcuts) <= width { - overlay_shortcuts - } else { - vec![("q", "Quit")] - } - } else if width < 44 { - vec![("q", "Quit")] - } else if width < 64 { - vec![ - ("r", "Sync"), - ("n", "New"), - ("Enter", "Open"), - ("q", "Quit"), - ] - } else { - vec![ - ("c", "Connect"), - ("r", "Sync"), - ("n", "New"), - ("Enter", "Open"), - ("q", "Quit"), - ] - } -} - -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) - .sum::() - .try_into() - .unwrap_or(u16::MAX) -} - -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::styled(key.to_string(), keycap_style())); - spans.push(Span::raw(format!(" {label}"))); - } - Line::from(spans) -} - -fn topbar_summary(app: &App) -> Option { - match (&app.connected_project, &app.connected_user) { - (Some(project), Some(user)) => Some(format!( - "{} @{}", - project.path_with_namespace, user.username - )), - (Some(project), None) => Some(project.path_with_namespace.clone()), - (None, Some(user)) => Some(format!("@{}", user.username)), - (None, None) => app - .story_status - .as_deref() - .filter(|status| status.starts_with("Config error: missing ")) - .map(|_| "Config missing".to_string()), - } -} - -fn render_palette(frame: &mut Frame, area: Rect, app: &mut App) { - let rect = centered(area, 56, 16); - frame.render_widget(Clear, rect); - let block = Block::new() - .title(" Board Actions ") - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(Color::Rgb(245, 158, 11))) - .style(Style::default().bg(SURFACE_RAISED)); - let inner = block.inner(rect); - frame.render_widget(block, rect); - - let input = Paragraph::new(Line::from(vec![ - Span::styled("Search ", Style::default().fg(Color::Rgb(148, 163, 184))), - Span::styled( - format!("{}▌", app.palette_query), - Style::default().fg(Color::Rgb(248, 250, 252)), - ), - ])) - .block( - Block::new() - .borders(Borders::ALL) - .border_type(BorderType::Plain) - .border_set(solid_border()) - .border_style(Style::default().fg(Color::Rgb(71, 85, 105))), - ); - frame.render_widget(input, Rect::new(inner.x, inner.y, inner.width, 3)); - - let commands = app.commands(); - for (index, command) in commands.iter().enumerate() { - let y = inner.y + 4 + index as u16; - if y >= inner.y + inner.height { - break; - } - let selected = index == app.palette_selected; - let row_style = if selected { - Style::default() - .fg(Color::Black) - .bg(Color::Rgb(245, 158, 11)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Rgb(226, 232, 240)) - .bg(Color::Rgb(15, 23, 42)) - }; - let marker = if selected { "▸" } else { " " }; - let line = Line::from(vec![ - Span::styled(format!(" {} {}", marker, command.name), row_style), - Span::styled(format!(" {:>8} ", command.shortcut), row_style), - ]); - let row = Rect::new(inner.x, y, inner.width, 1); - frame.render_widget(Paragraph::new(line).style(row_style), row); - app.add_hitbox(row, ClickTarget::PaletteCommand { index }); - } -} - -fn render_issue_modal(frame: &mut Frame, area: Rect, app: &mut App, is_new: bool) { - let rect = centered(area, 62, 20); - let issue_id = if is_new { - app.next_iid() - } else { - let Some(issue) = app.selected_issue_opt() else { - return; - }; - issue.iid - }; - let shadow = Rect::new( - rect.x.saturating_add(2), - rect.y.saturating_add(1), - rect.width - .min(area.width.saturating_sub(rect.x.saturating_add(2))), - rect.height - .min(area.height.saturating_sub(rect.y.saturating_add(1))), - ); - frame.render_widget( - Block::new().style(Style::default().bg(SURFACE_SHADOW)), - shadow, - ); - frame.render_widget(Clear, rect); - frame.render_widget( - Block::new().style(Style::default().bg(SURFACE_RAISED)), - rect, - ); - - let header = Rect::new(rect.x, rect.y, rect.width, 1); - let header_text = Text::from(Line::from(Span::styled( - format!(" Issue #{} ", issue_id), - Style::default() - .fg(Color::Black) - .bg(AMBER) - .add_modifier(Modifier::BOLD), - ))); - frame.render_widget(Paragraph::new(header_text), header); - - let inner = Rect::new( - rect.x.saturating_add(2), - rect.y.saturating_add(2), - rect.width.saturating_sub(4), - rect.height.saturating_sub(4), - ); - - let title_text = app.editor_title.as_str(); - let description_text = app.editor_description.as_str(); - let title_cursor = if app.editor_field == EditorField::Title { - "▌" - } else { - "" - }; - let description_active = app.editor_field == EditorField::Description; - let description_label = if app.editor_preview { - "Description Preview" - } else if description_active { - "Description Edit" - } else { - "Description" - }; - let hint = if app.editor_preview { - "Ctrl+P Edit. PgUp/PgDn Scroll. Ctrl+S Save." - } else if description_active { - "Enter newline. Up/Down or PgUp/PgDn scroll. Ctrl+P Preview. Ctrl+S Save." - } else { - "Tab switches field. Enter saves." - }; - let labels = if app.editor_labels.is_empty() { - "(none)".to_string() - } else { - app.editor_labels.join(", ") - }; - let ai_hint = if app.ai_job_in_flight { - "Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel AI." - } else { - "Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel." - }; - let mut lines = vec![ - Line::from(vec![ - Span::styled("Issue ", Style::default().fg(TEXT_MUTED)), - Span::styled( - title_text, - Style::default().fg(TEXT).add_modifier(Modifier::BOLD), - ), - ]), - Line::raw(""), - Line::from(vec![Span::styled( - if app.editor_field == EditorField::Title { - "Title Active" - } else { - "Title" - }, - Style::default().fg(TEXT_MUTED), - )]), - Line::from(vec![Span::styled( - format!(" {}{}", title_text, title_cursor), - editor_value_style(app.editor_field == EditorField::Title), - )]), - Line::raw(""), - Line::from(vec![Span::styled( - description_label, - Style::default().fg(TEXT_MUTED), - )]), - ]; - lines.extend(description_lines( - description_text, - description_active, - app.editor_preview, - )); - lines.extend([ - Line::raw(""), - Line::from(vec![Span::styled( - "Labels", - Style::default().fg(TEXT_MUTED), - )]), - Line::from(vec![Span::styled( - format!(" {labels}"), - editor_value_style(false), - )]), - Line::raw(""), - Line::from(vec![Span::styled(ai_hint, Style::default().fg(SLATE))]), - Line::from(vec![Span::styled(hint, Style::default().fg(SLATE))]), - ]); - let content = Text::from(lines); - frame.render_widget( - Paragraph::new(content).wrap(Wrap { trim: false }).scroll(( - app.editor_description_scroll.min(u16::MAX as usize) as u16, - 0, - )), - Rect::new(inner.x, inner.y, inner.width, inner.height), - ); - - let button_y = rect.y + rect.height.saturating_sub(2); - frame.render_widget( - Paragraph::new(" ").style(Style::default().bg(SURFACE_RAISED)), - Rect::new(rect.x, button_y.saturating_sub(1), rect.width, 2), - ); - let save = Rect::new(inner.x, button_y, 10, 1); - let cancel = Rect::new(inner.x + 12, button_y, 8, 1); - render_button(frame, save, "Save", true); - render_button(frame, cancel, "Cancel", false); - app.add_hitbox(save, ClickTarget::Save); - app.add_hitbox(cancel, ClickTarget::Cancel); -} - -fn render_notes_modal(frame: &mut Frame, area: Rect, app: &mut App) { - let rect = centered(area, 62, 18); - let shadow = Rect::new( - rect.x.saturating_add(2), - rect.y.saturating_add(1), - rect.width - .min(area.width.saturating_sub(rect.x.saturating_add(2))), - rect.height - .min(area.height.saturating_sub(rect.y.saturating_add(1))), - ); - frame.render_widget( - Block::new().style(Style::default().bg(SURFACE_SHADOW)), - shadow, - ); - frame.render_widget(Clear, rect); - frame.render_widget( - Block::new().style(Style::default().bg(SURFACE_RAISED)), - rect, - ); - - let iid = app - .notes_issue_iid - .unwrap_or_else(|| app.selected_issue().iid); - frame.render_widget( - Paragraph::new(format!(" Notes #{} ", iid)).style(button_primary_style()), - Rect::new(rect.x, rect.y, 14, 1), - ); - - let body = Rect::new( - rect.x + 2, - rect.y + 2, - rect.width.saturating_sub(4), - rect.height.saturating_sub(5), - ); - let mut lines = vec![Line::from(vec![Span::styled( - "Latest Notes", - Style::default().fg(TEXT_MUTED), - )])]; - if app.notes.is_empty() { - lines.push(Line::from(vec![Span::styled( - " No notes yet", - Style::default().fg(SLATE), - )])); - } else { - for note in app.notes.iter().rev().take(5) { - lines.push(Line::from(vec![Span::styled( - format!("- {}", note.body.lines().next().unwrap_or_default()), - Style::default().fg(TEXT), - )])); - } - } - lines.push(Line::raw("")); - lines.push(Line::from(vec![Span::styled( - "Add Note", - Style::default().fg(TEXT_MUTED), - )])); - if app.note_input.is_empty() { - lines.push(Line::from(vec![Span::styled( - " Type note here", - editor_value_style(true), - )])); - } else { - for line in app.note_input.lines() { - lines.push(Line::from(vec![Span::styled( - format!(" {line}"), - editor_value_style(true), - )])); - } - } - lines.push(Line::raw("")); - lines.push(Line::from(vec![Span::styled( - "Ctrl+S save. Enter newline. Esc close.", - Style::default().fg(SLATE), - )])); - lines.push(Line::from(vec![Span::styled( - "Ctrl+P pull source snapshot.", - Style::default().fg(SLATE), - )])); - frame.render_widget( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), - body, - ); - - let button_y = rect.y + rect.height.saturating_sub(2); - let save = Rect::new(rect.x + 2, button_y, 10, 1); - let cancel = Rect::new(rect.x + 14, button_y, 8, 1); - render_button(frame, save, "Save", true); - render_button(frame, cancel, "Cancel", false); - app.add_hitbox(save, ClickTarget::Save); - app.add_hitbox(cancel, ClickTarget::Cancel); -} - -fn render_generate_stories_modal(frame: &mut Frame, area: Rect, app: &mut App) { - let rect = centered(area, 76, 22); - let shadow = Rect::new( - rect.x.saturating_add(2), - rect.y.saturating_add(1), - rect.width - .min(area.width.saturating_sub(rect.x.saturating_add(2))), - rect.height - .min(area.height.saturating_sub(rect.y.saturating_add(1))), - ); - frame.render_widget( - Block::new().style(Style::default().bg(SURFACE_SHADOW)), - shadow, - ); - frame.render_widget(Clear, rect); - frame.render_widget( - Block::new().style(Style::default().bg(SURFACE_RAISED)), - rect, - ); - - frame.render_widget( - Paragraph::new(" Generate Stories ").style(button_primary_style()), - Rect::new(rect.x, rect.y, 18, 1), - ); - - let body = Rect::new( - rect.x + 2, - rect.y + 2, - rect.width.saturating_sub(4), - rect.height.saturating_sub(5), - ); - let source_label = if app.story_focus == StoryModalFocus::Source { - "Description or ADR Active" - } else { - "Description or ADR" - }; - let mut lines = vec![Line::from(vec![Span::styled( - source_label, - Style::default().fg(TEXT_MUTED), - )])]; - if app.story_source.is_empty() { - lines.push(Line::from(vec![Span::styled( - " Paste Markdown source here", - editor_value_style(app.story_focus == StoryModalFocus::Source), - )])); - } else { - for line in app.story_source.lines().take(6) { - lines.push(Line::from(vec![Span::styled( - format!(" {line}"), - editor_value_style(app.story_focus == StoryModalFocus::Source), - )])); - } - } - lines.push(Line::raw("")); - let list_label = match app.story_focus { - StoryModalFocus::List => "Generated Stories Active", - StoryModalFocus::Edit => "Generated Stories Editing Title", - StoryModalFocus::Source => "Generated Stories", - }; - lines.push(Line::from(vec![Span::styled( - list_label, - Style::default().fg(TEXT_MUTED), - )])); - if let Some(status) = &app.story_status { - lines.push(Line::from(vec![Span::styled( - status.clone(), - Style::default().fg(SLATE), - )])); - } - for (index, draft) in app.generated_stories.iter().take(6).enumerate() { - let mark = if draft.selected { "☑" } else { "☐" }; - let selected = app.story_selected == index; - let title = if app.story_focus == StoryModalFocus::Edit && selected { - format!("{}▌", draft.story.title) - } else { - draft.story.title.clone() - }; - lines.push(Line::from(vec![ - Span::styled( - if selected { "> " } else { " " }, - Style::default().fg(AMBER), - ), - Span::styled(format!("{mark} "), Style::default().fg(GREEN)), - Span::styled( - title, - Style::default() - .fg(TEXT) - .bg(if selected { SURFACE } else { SURFACE_RAISED }) - .add_modifier(Modifier::BOLD), - ), - ])); - } - lines.push(Line::raw("")); - let hint = match app.story_focus { - StoryModalFocus::Source => "Ctrl+G generate. Tab list. Ctrl+S create selected. Esc close.", - StoryModalFocus::List => { - "Up/Down select. Space toggle. Enter edit title. Tab source/edit. Ctrl+S create." - } - StoryModalFocus::Edit => { - "Type title. Backspace delete. Enter done. Tab source. Ctrl+S create." - } - }; - lines.push(Line::from(vec![Span::styled( - hint, - Style::default().fg(SLATE), - )])); - - frame.render_widget( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), - body, - ); - - let button_y = rect.y + rect.height.saturating_sub(2); - frame.render_widget( - Paragraph::new(" ").style(Style::default().bg(SURFACE_RAISED)), - Rect::new(rect.x, button_y.saturating_sub(1), rect.width, 2), - ); - let create = Rect::new(rect.x + 2, button_y, 17, 1); - let cancel = Rect::new(rect.x + 21, button_y, 8, 1); - render_button(frame, create, "Create Selected", true); - render_button(frame, cancel, "Cancel", false); - app.add_hitbox(create, ClickTarget::CreateSelectedStories); - app.add_hitbox(cancel, ClickTarget::Cancel); -} - -fn render_button(frame: &mut Frame, area: Rect, label: &str, primary: bool) { - let style = if primary { - button_primary_style() - } else { - button_secondary_style() - }; - frame.render_widget( - Paragraph::new(label) - .alignment(Alignment::Center) - .style(style), - area, - ); -} - -fn description_lines(markdown: &str, active: bool, preview: bool) -> Vec> { - if preview { - return markdown_preview_lines(markdown); - } - markdown_source_lines(markdown, active) -} - -fn markdown_source_lines(markdown: &str, active: bool) -> Vec> { - let style = editor_value_style(active); - let cursor = if active { "▌" } else { "" }; - if markdown.is_empty() { - return vec![Line::from(vec![Span::styled(format!(" {cursor}"), style)])]; - } - - let mut lines: Vec> = markdown - .split('\n') - .map(|line| Line::from(vec![Span::styled(format!(" {line}"), style)])) - .collect(); - if active { - if let Some(last) = lines.last_mut() { - last.spans.push(Span::styled(cursor, style)); - } - } - lines -} - -fn markdown_preview_lines(markdown: &str) -> Vec> { - if markdown.trim().is_empty() { - return vec![Line::from(vec![Span::styled( - "No description yet", - Style::default().fg(TEXT_MUTED), - )])]; - } - - let mut lines = Vec::new(); - let mut in_code = false; - for raw_line in markdown.lines() { - let line = raw_line.trim_end(); - let trimmed = line.trim_start(); - - if trimmed.starts_with("```") { - in_code = !in_code; - continue; - } - - if in_code { - lines.push(Line::from(vec![Span::styled( - format!(" {line}"), - code_style(), - )])); - continue; - } - - if trimmed.is_empty() { - lines.push(Line::raw("")); - continue; - } - - if let Some((level, heading)) = markdown_heading(trimmed) { - let style = if level == 1 { - Style::default().fg(AMBER).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(TEXT).add_modifier(Modifier::BOLD) - }; - lines.push(Line::from(vec![Span::styled( - heading.to_uppercase(), - style, - )])); - continue; - } - - if let Some(task) = trimmed - .strip_prefix("- [x] ") - .or_else(|| trimmed.strip_prefix("- [X] ")) - { - lines.push(Line::from(vec![ - Span::styled( - "☑ ", - Style::default().fg(GREEN).add_modifier(Modifier::BOLD), - ), - Span::styled(task.to_string(), Style::default().fg(TEXT)), - ])); - continue; - } - - if let Some(task) = trimmed.strip_prefix("- [ ] ") { - lines.push(Line::from(vec![ - Span::styled("☐ ", Style::default().fg(TEXT_MUTED)), - Span::styled(task.to_string(), Style::default().fg(TEXT)), - ])); - continue; - } - - if let Some(item) = trimmed - .strip_prefix("- ") - .or_else(|| trimmed.strip_prefix("* ")) - { - let mut spans = vec![Span::styled("• ", Style::default().fg(AMBER))]; - spans.extend(markdown_inline_spans(item, Style::default().fg(TEXT))); - lines.push(Line::from(spans)); - continue; - } - - if let Some(quote) = trimmed.strip_prefix("> ") { - let mut spans = vec![Span::styled("▎ ", Style::default().fg(SLATE))]; - spans.extend(markdown_inline_spans( - quote, - Style::default().fg(TEXT_MUTED), - )); - lines.push(Line::from(spans)); - continue; - } - - lines.push(Line::from(markdown_inline_spans( - trimmed, - Style::default().fg(TEXT), - ))); - } - - lines -} - -fn markdown_heading(line: &str) -> Option<(usize, &str)> { - let level = line.chars().take_while(|ch| *ch == '#').count(); - if (1..=3).contains(&level) && line.chars().nth(level) == Some(' ') { - Some((level, line[level + 1..].trim())) - } else { - None - } -} - -fn markdown_inline_spans(input: &str, base_style: Style) -> Vec> { - let mut spans = Vec::new(); - let mut rest = input; - - while !rest.is_empty() { - if let Some(stripped) = rest.strip_prefix("**") { - if let Some(end) = stripped.find("**") { - spans.push(Span::styled( - stripped[..end].to_string(), - base_style.add_modifier(Modifier::BOLD), - )); - rest = &stripped[end + 2..]; - continue; - } - } - - if let Some(stripped) = rest.strip_prefix('`') { - if let Some(end) = stripped.find('`') { - spans.push(Span::styled(stripped[..end].to_string(), code_style())); - rest = &stripped[end + 1..]; - continue; - } - } - - if let Some(link) = markdown_link(rest) { - spans.push(Span::styled( - link.text.to_string(), - Style::default().fg(Color::Rgb(96, 165, 250)), - )); - rest = &rest[link.end..]; - continue; - } - - if let Some(stripped) = rest.strip_prefix('_') { - if let Some(end) = stripped.find('_') { - spans.push(Span::styled( - stripped[..end].to_string(), - Style::default().fg(Color::Rgb(203, 213, 225)), - )); - rest = &stripped[end + 1..]; - continue; - } - } - - let next = rest - .char_indices() - .skip(1) - .find_map(|(index, ch)| { - (ch == '*' || ch == '`' || ch == '[' || ch == '_').then_some(index) - }) - .unwrap_or(rest.len()); - spans.push(Span::styled(rest[..next].to_string(), base_style)); - rest = &rest[next..]; - } - - spans -} - -struct MarkdownLink<'a> { - text: &'a str, - end: usize, -} - -fn markdown_link(input: &str) -> Option> { - if !input.starts_with('[') { - return None; - } - let close_text = input.find("](")?; - let after_open_url = close_text + 2; - let close_url = input[after_open_url..].find(')')? + after_open_url; - Some(MarkdownLink { - text: &input[1..close_text], - end: close_url + 1, - }) -} - -fn code_style() -> Style { - Style::default() - .fg(Color::Rgb(226, 232, 240)) - .bg(Color::Black) -} - -fn editor_value_style(active: bool) -> Style { - if active { - Style::default() - .fg(Color::Black) - .bg(Color::Rgb(245, 158, 11)) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Rgb(226, 232, 240)) - .bg(Color::Rgb(15, 23, 42)) - } -} - -fn centered(area: Rect, width: u16, height: u16) -> Rect { - let width = width.min(area.width.saturating_sub(4)).max(20); - let height = height.min(area.height.saturating_sub(4)).max(10); - Rect::new( - area.x + area.width.saturating_sub(width) / 2, - area.y + area.height.saturating_sub(height) / 2, - width, - height, - ) -} - -fn state_for(index: usize) -> LaneState { - match index { - 0 => LaneState::Todo, - 1 => LaneState::Doing, - _ => LaneState::Done, - } -} - -fn state_color(state: LaneState) -> Color { - match state { - LaneState::Todo => Color::Rgb(245, 158, 11), - LaneState::Doing => Color::Rgb(34, 197, 94), - LaneState::Done => Color::Rgb(100, 116, 139), - } -} - -fn solid_border() -> border::Set<'static> { - border::Set { - top_left: "┌", - top_right: "┐", - bottom_left: "└", - bottom_right: "┘", - vertical_left: "│", - vertical_right: "│", - horizontal_top: "─", - horizontal_bottom: "─", - } -} - -fn button_primary_style() -> Style { - Style::default() - .fg(Color::Black) - .bg(Color::Rgb(245, 158, 11)) - .add_modifier(Modifier::BOLD) -} - -fn button_secondary_style() -> Style { - Style::default() - .fg(Color::Black) - .bg(Color::Rgb(34, 197, 94)) - .add_modifier(Modifier::BOLD) -} - -fn chip_style(color: Color) -> Style { - Style::default().fg(color).bg(INK) -} - -fn keycap_style() -> Style { - Style::default() - .fg(Color::Rgb(248, 250, 252)) - .bg(Color::Rgb(51, 65, 85)) - .add_modifier(Modifier::BOLD) -} - -fn blocked_style() -> Style { - Style::default() - .fg(Color::White) - .bg(Color::Rgb(220, 38, 38)) - .add_modifier(Modifier::BOLD) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::{BoardState, GitLabNote, IssueRecord, Origin}; - use crate::storage::FileStore; - - 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") - } - - fn render_to_string(width: u16, height: u16, app: &mut App) -> String { - let backend = ratatui::backend::TestBackend::new(width, height); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - - terminal.draw(|frame| render(frame, app)).expect("render"); - - terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect() - } - - fn screen_row(screen: &str, width: usize, row: usize) -> String { - screen.chars().skip(width * row).take(width).collect() - } - - fn region_text(buffer: &ratatui::buffer::Buffer, width: usize, rect: Rect) -> String { - let mut text = String::new(); - for y in rect.y..rect.y + rect.height { - for x in rect.x..rect.x + rect.width { - text.push_str(buffer.content()[y as usize * width + x as usize].symbol()); - } - } - text - } - - fn bg_at(buffer: &ratatui::buffer::Buffer, width: usize, x: usize, y: usize) -> Color { - buffer.content()[y * width + x].bg - } - - fn issue_record(iid: u64, title: &str, state: BoardState) -> IssueRecord { - IssueRecord { - id: iid, - iid, - project_id: 42, - title: title.into(), - description: String::new(), - state, - labels: Vec::new(), - due_date: None, - weight: None, - web_url: format!("https://gitlab.example.com/me/lane/-/issues/{iid}"), - reference: format!("me/lane#{iid}"), - flags: Vec::new(), - origin: Origin::Side, - source: None, - note_count: None, - updated_at: None, - } - } - - #[test] - fn app_chrome_uses_pure_black_background() { - 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("board render"); - let buffer = terminal.backend().buffer(); - - assert_eq!(INK, Color::Black); - assert_eq!(bg_at(buffer, 120, 1, 1), Color::Black); - assert_eq!(bg_at(buffer, 120, 1, 4), Color::Black); - assert_eq!(bg_at(buffer, 120, 1, 34), Color::Black); - } - - #[test] - fn issue_modal_uses_gray_surface_behind_buttons() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - app.open_details(); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let buffer = terminal.backend().buffer(); - let modal = centered(Rect::new(0, 0, 120, 36), 62, 20); - let inner = Rect::new( - modal.x.saturating_add(2), - modal.y.saturating_add(3), - modal.width.saturating_sub(4), - modal.height.saturating_sub(5), - ); - let button_y = modal.y + modal.height.saturating_sub(2); - let popup_gray = Color::Rgb(39, 39, 42); - - assert_eq!(SURFACE_RAISED, popup_gray); - assert_eq!( - bg_at(buffer, 120, inner.x as usize, inner.y as usize), - popup_gray - ); - assert_eq!( - bg_at(buffer, 120, (inner.x + 11) as usize, button_y as usize), - popup_gray - ); - assert_eq!( - bg_at( - buffer, - 120, - (inner.x + 22) as usize, - button_y.saturating_sub(1) as usize, - ), - popup_gray - ); - } - - #[test] - fn topbar_centers_app_name_and_hides_command_button() { - let mut app = App::demo(); - let screen = render_to_string(120, 36, &mut app); - let topbar_row = screen_row(&screen, 120, 1); - let name_offset = char_offset(&topbar_row, "Lane"); - let name_center = name_offset + "Lane".chars().count() / 2; - - assert!(name_center.abs_diff(60) <= 1, "topbar row: {topbar_row:?}"); - assert!(screen.contains("New Issue")); - assert!(!screen.contains("Command")); - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::NewIssue)); - assert!(!app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::Command)); - } - - #[test] - fn topbar_renders_generate_stories_next_to_new_issue() { - let mut app = App::demo(); - let screen = render_to_string(120, 36, &mut app); - - let new_issue = char_offset(&screen, "New Issue"); - let generate = char_offset(&screen, "Generate Stories"); - - assert!(generate > new_issue); - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::GenerateStories)); - } - - #[test] - fn topbar_exposes_connect_and_sync_controls() { - let mut app = App::demo(); - let screen = render_to_string(120, 36, &mut app); - let topbar_row = screen_row(&screen, 120, 1); - - assert!(topbar_row.contains("Connect"), "topbar: {topbar_row:?}"); - assert!(topbar_row.contains("Sync"), "topbar: {topbar_row:?}"); - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::Connect)); - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::SyncAll)); - } - - #[test] - fn generate_stories_modal_edits_source_markdown_and_holds_drafts() { - let mut app = App::demo(); - - app.open_generate_stories(); - for ch in "# ADR\nUse GitLab sync".chars() { - if ch == '\n' { - app.input_story_source_newline(); - } else { - app.input_story_source_char(ch); - } - } - app.set_generated_stories(vec![crate::model::GeneratedStory { - title: "Sync assigned issues".into(), - description: "Pull and fork issues".into(), - acceptance_criteria: vec!["Sync button creates issues".into()], - labels: vec!["backend".into()], - priority: Some("P1".into()), - size: Some("M".into()), - }]); - - assert_eq!(app.story_source, "# ADR\nUse GitLab sync"); - assert_eq!(app.generated_stories.len(), 1); - assert!(app.generated_stories[0].selected); - } - - #[test] - fn board_uses_issue_tracking_language() { - let mut app = App::demo(); - let screen = render_to_string(120, 36, &mut app); - - assert!(screen.contains("TODO 09")); - assert!(screen.contains("#101")); - assert!(!screen.contains("LANE TODO")); - assert!(!screen.contains("IID")); - } - - #[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("Enter Open")); - assert!(footer.contains("b Block")); - assert!(footer.contains("Arrows Move")); - assert!(footer.contains("q Quit")); - assert!(!footer.contains("Esc Close")); - assert!(!footer.contains("FOCUS")); - assert!(!footer.contains("READY")); - assert!(!footer.contains("DRAFT USER STORY")); - assert!(!footer.contains("command")); - } - - #[test] - fn status_surface_shows_missing_config_in_topbar() { - let mut app = App::demo(); - app.story_status = Some("Config error: missing LANE_GITLAB_URL".into()); - - let screen = render_to_string(120, 36, &mut app); - let topbar = screen_row(&screen, 120, 1); - - assert!(topbar.contains("Config missing"), "topbar: {topbar:?}"); - } - - #[test] - fn status_surface_uses_gauge_widget_while_connecting() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - app.story_status = Some("Connecting to GitLab".into()); - app.loading_tick = 6; - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let buffer = terminal.backend().buffer(); - let footer = screen_row( - &buffer - .content() - .iter() - .map(|cell| cell.symbol()) - .collect::(), - 120, - 34, - ); - - assert!( - footer.contains("Connecting to GitLab"), - "footer: {footer:?}" - ); - assert!( - (18..52).any(|x| bg_at(buffer, 120, x, 34) == AMBER), - "connecting indicator should use Ratatui Gauge styling" - ); - } - - #[test] - fn status_surface_shows_connection_context_and_job_status() { - let mut app = App::demo(); - app.connected_user = Some(GitLabUser { - id: 7, - username: "alice".into(), - name: "Alice Example".into(), - }); - app.connected_project = Some(GitLabProject { - id: 42, - path_with_namespace: "me/lane".into(), - web_url: "https://gitlab.example.com/me/lane".into(), - }); - app.story_status = Some("Bootstrap: 2 created, 6 already present".into()); - - let screen = render_to_string(120, 36, &mut app); - let topbar = screen_row(&screen, 120, 1); - let footer = screen_row(&screen, 120, 34); - - assert!(topbar.contains("me/lane"), "topbar: {topbar:?}"); - assert!(topbar.contains("@alice"), "topbar: {topbar:?}"); - assert!( - footer.contains("Bootstrap: 2 created, 6 already present"), - "footer: {footer:?}" - ); - } - - #[test] - fn connected_reorder_persists_sidecar_order() { - let root = std::env::temp_dir().join(format!("lane-test-order-{}", std::process::id())); - let store = FileStore::new(root.clone(), "https://gitlab.example.com", 42); - 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 = - std::env::temp_dir().join(format!("lane-test-apply-order-{}", std::process::id())); - 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); + #[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), @@ -3250,31 +2146,9 @@ mod tests { assert!(app.ai_job_in_flight); } - #[test] - fn notes_modal_shows_latest_notes_first() { - let mut app = App::demo(); - app.overlay = Some(Overlay::Notes); - app.notes_issue_iid = Some(101); - app.notes = (1..=6) - .map(|id| GitLabNote { - id, - body: format!("Note {id}"), - created_at: None, - }) - .collect(); - - let screen = render_to_string(120, 36, &mut app); - - assert!( - screen.find("Note 6").unwrap() < screen.find("Note 5").unwrap(), - "screen: {screen:?}" - ); - assert!(!screen.contains("Note 1")); - } - #[test] fn toggle_blocked_persists_empty_reason_and_clears_when_removed() { - let root = std::env::temp_dir().join(format!("lane-test-flag-{}", std::process::id())); + 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()); @@ -3298,116 +2172,6 @@ mod tests { let _ = std::fs::remove_dir_all(root); } - #[test] - fn status_surface_preserves_critical_shortcuts_when_status_is_long() { - let mut app = App::demo(); - app.story_status = Some( - "Bootstrap failed: very long GitLab error body should not hide close and quit hints" - .into(), - ); - - let screen = render_to_string(32, 36, &mut app); - let footer = screen_row(&screen, 32, 34); - - assert!(footer.contains("Bootstrap"), "footer: {footer:?}"); - assert!(footer.contains("q Quit"), "footer: {footer:?}"); - assert!(!footer.contains("Esc Close"), "footer: {footer:?}"); - assert!( - !footer.contains("very long GitLab error body"), - "footer: {footer:?}" - ); - } - - #[test] - fn status_surface_preserves_overlay_close_shortcut_when_narrow() { - let mut app = App::demo(); - app.overlay = Some(Overlay::Details); - app.story_status = Some("Bootstrap failed: remote GitLab unavailable".into()); - - let screen = render_to_string(32, 36, &mut app); - let footer = screen_row(&screen, 32, 34); - - assert!(footer.contains("Bootstrap"), "footer: {footer:?}"); - assert!(footer.contains("Esc Close"), "footer: {footer:?}"); - assert!(footer.contains("q Quit"), "footer: {footer:?}"); - } - - #[test] - fn status_surface_preserves_overlay_close_shortcut_at_tiny_width() { - let mut app = App::demo(); - app.overlay = Some(Overlay::Details); - app.story_status = Some("Bootstrap failed".into()); - - let screen = render_to_string(22, 36, &mut app); - let footer = screen_row(&screen, 22, 34); - - assert!(footer.contains("Esc Close"), "footer: {footer:?}"); - assert!(footer.contains("q Quit"), "footer: {footer:?}"); - } - - #[test] - fn modal_uses_production_issue_copy() { - let mut app = App::demo(); - app.open_details(); - let screen = render_to_string(120, 36, &mut app); - - assert!(screen.contains("Issue #101")); - assert!(screen.contains("Title")); - assert!(screen.contains("Description")); - assert!(screen.contains("Save")); - assert!(screen.contains("Cancel")); - for stale in [ - "ORDER", - "LOCKED TICKET", - "ACTIVE ORDER", - "POP OUT PANEL", - "BACKGROUND DISABLED", - "VIEWPORT DISABLED", - "Edit Issue", - "Update issue details", - ] { - assert!(!screen.contains(stale), "stale modal copy: {stale}"); - } - } - - #[test] - fn issue_modal_removes_redundant_mode_header_and_subtitle() { - for (is_new, issue_id, redundant) in [ - (false, "Issue #101", ["Edit Issue", "Update issue details"]), - (true, "Issue #407", ["New Issue", "Create in TODO"]), - ] { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - if is_new { - app.open_new_issue(); - } else { - app.open_details(); - } - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let modal = centered(Rect::new(0, 0, 120, 36), 62, 20); - let modal_text = region_text(terminal.backend().buffer(), 120, modal); - - assert!(modal_text.contains(issue_id)); - for text in redundant { - assert!(!modal_text.contains(text), "redundant modal copy: {text}"); - } - } - } - - #[test] - fn palette_uses_board_action_language() { - let mut app = App::demo(); - app.open_palette(); - let screen = render_to_string(120, 36, &mut app); - - assert!(screen.contains("Board Actions")); - assert!(!screen.contains("BROKER COMMAND")); - } - #[test] fn palette_lists_sync_and_generate_stories_actions() { let app = App::demo(); @@ -3421,6 +2185,11 @@ mod tests { assert!(commands .iter() .any(|command| command.name == "Generate Stories")); + assert!(commands.iter().any(|command| command.name == "Settings")); + assert!(commands.iter().any(|command| command.name == "Help")); + assert!(commands + .iter() + .any(|command| command.name == "Delete issue")); } #[test] @@ -3462,61 +2231,19 @@ mod tests { } #[test] - fn clicking_palette_row_executes_command() { + fn palette_selection_opens_settings_and_help() { let mut app = App::demo(); app.open_palette(); - app.palette_query = "generate".into(); - let _screen = render_to_string(120, 36, &mut app); - let target = app - .hitboxes - .iter() - .find_map(|hitbox| match hitbox.target { - ClickTarget::PaletteCommand { index } => { - Some(ClickTarget::PaletteCommand { index }) - } - _ => None, - }) - .expect("palette command hitbox"); - - app.click(target); - - assert_eq!(app.overlay, Some(Overlay::GenerateStories)); - } - - #[test] - fn narrow_terminal_drag_render_does_not_panic() { - let mut app = App::demo(); - app.drag = Some(DragState { - source_column: 0, - source_index: 0, - current_x: 8, - current_y: 4, - over: None, - moved: true, - }); - - let _screen = render_to_string(12, 8, &mut app); - } - - #[test] - fn new_issue_modal_renders_after_empty_sync() { - let mut app = App::demo(); - app.replace_issue_records(&[]); - app.open_new_issue(); - - let screen = render_to_string(120, 36, &mut app); - - assert!(screen.contains("Issue #101")); - } + app.palette_query = "settings".into(); + app.execute_palette_selection(); + assert_eq!(app.overlay, Some(Overlay::Settings)); + assert!(app.settings_form.is_some()); - #[test] - fn open_details_noops_on_empty_board() { let mut app = App::demo(); - app.replace_issue_records(&[]); - - app.open_details(); - - assert_eq!(app.overlay, None); + app.open_palette(); + app.palette_query = "help".into(); + app.execute_palette_selection(); + assert_eq!(app.overlay, Some(Overlay::Help)); } #[test] @@ -3537,421 +2264,308 @@ mod tests { } #[test] - fn keyboard_actions_open_close_and_toggle_selected_issue() { + fn search_filters_navigation_to_matching_issues() { let mut app = App::demo(); + app.start_search(); + for ch in "mouse".chars() { + app.input_search_char(ch); + } - app.open_details(); - assert_eq!(app.overlay, Some(Overlay::Details)); - - app.close_overlay(); - assert_eq!(app.overlay, None); - - app.open_palette(); - assert_eq!(app.overlay, Some(Overlay::Palette)); + assert_eq!(app.visible_rows(0), vec![8]); + assert_eq!(app.visible_rows(1), vec![2]); + assert_eq!(app.search_match_count(), 2); - app.open_new_issue(); - assert_eq!(app.overlay, Some(Overlay::NewIssue)); + assert_eq!(app.selected_issue().iid, 109); - app.close_overlay(); - app.toggle_blocked(); - assert!(app.selected_issue().blocked); + app.move_issue_down(); + assert_eq!(app.selected_issue().iid, 109); + app.move_issue_up(); + assert_eq!(app.selected_issue().iid, 109); - app.toggle_blocked(); - assert!(!app.selected_issue().blocked); + app.end_search(false); + assert_eq!(app.search_query, ""); + assert_eq!(app.visible_rows(0).len(), 9); } #[test] - fn mouse_targets_select_issue_and_activate_buttons() { + fn search_matches_issue_ids_and_labels() { let mut app = App::demo(); + app.start_search(); + for ch in "#101".chars() { + app.input_search_char(ch); + } + assert_eq!(app.visible_rows(0), vec![0]); - app.click(ClickTarget::Issue { - column: 2, - index: 0, - }); - assert_eq!(app.active_column, 2); - assert_eq!(app.selected_issue().iid, 401); - assert_eq!(app.overlay, Some(Overlay::Details)); - - app.click(ClickTarget::Cancel); - assert_eq!(app.overlay, None); - - app.click(ClickTarget::NewIssue); - assert_eq!(app.overlay, Some(Overlay::NewIssue)); - - app.click(ClickTarget::Save); - assert_eq!(app.overlay, None); - - app.click(ClickTarget::Command); - assert_eq!(app.overlay, Some(Overlay::Palette)); + app.end_search(false); + app.start_search(); + for ch in "a11y".chars() { + app.input_search_char(ch); + } + assert_eq!(app.visible_rows(0), vec![4]); } - #[test] - fn render_populates_board_and_overlay_hitboxes() { - let backend = ratatui::backend::TestBackend::new(100, 32); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - - assert!(app.hitboxes.iter().any(|hitbox| hitbox.target - == ClickTarget::Issue { - column: 0, - index: 0 - })); - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::NewIssue)); - assert!(!app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::Command)); - - app.open_details(); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::Save)); - assert!(app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::Cancel)); + #[test] + fn search_query_persists_when_input_defocused_with_keep() { + let mut app = App::demo(); + app.start_search(); + app.input_search_char('u'); + app.input_search_char('x'); + app.end_search(true); + + assert!(!app.search_active); + assert_eq!(app.search_query, "ux"); + assert_eq!(app.visible_rows(0), vec![0]); } #[test] - fn board_renders_three_solid_lanes() { - let backend = ratatui::backend::TestBackend::new(100, 32); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn delete_issue_requires_confirmation_by_default() { let mut app = App::demo(); + assert!(app.settings.advanced.confirm_destructive); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); + app.request_delete_issue(); + + assert_eq!(app.overlay, Some(Overlay::Confirm)); + let confirm = app.confirm.clone().expect("confirm request"); + assert_eq!(confirm.action, ConfirmAction::DeleteIssue { iid: 101 }); + assert!(confirm.title.contains("#101")); - assert_eq!(app.columns.len(), 3); - assert!(screen.contains("TODO")); - assert!(screen.contains("IN PROGRESS")); - assert!(screen.contains("DONE")); - assert!(!screen.contains("REVIEW")); - assert!(screen.contains("┌")); - assert!(screen.contains("─")); - assert!(screen.contains("│")); - assert!(!screen.contains("+")); + app.confirm_accept(); + assert_eq!(app.overlay, None); + assert!(app.confirm.is_none()); + assert!(!app.columns[0].iter().any(|issue| issue.iid == 101)); + assert_eq!(app.story_status.as_deref(), Some("Deleted issue #101")); + assert_eq!(app.status_kind, StatusKind::Success); } #[test] - fn board_renders_issue_tracking_chrome() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn delete_issue_confirm_cancel_keeps_issue() { let mut app = App::demo(); + app.request_delete_issue(); + app.confirm_cancel(); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); - - assert!(screen.contains("TODO 09")); - assert!(screen.contains("#101")); + assert_eq!(app.overlay, None); + assert!(app.confirm.is_none()); + assert!(app.columns[0].iter().any(|issue| issue.iid == 101)); + assert_eq!(app.story_status.as_deref(), Some("Cancelled")); } #[test] - fn topbar_fake_telemetry_is_removed() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn delete_issue_queues_job_when_connected() { let mut app = App::demo(); + app.settings.advanced.confirm_destructive = false; + app.connected_project = Some(GitLabProject { + id: 42, + path_with_namespace: "me/lane".into(), + web_url: "https://gitlab.example.com/me/lane".into(), + }); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let topbar: String = terminal - .backend() - .buffer() - .content() - .iter() - .take(120 * 3) - .map(|cell| cell.symbol()) - .collect(); + app.request_delete_issue(); - assert!(!topbar.contains("HOT")); - assert!(!topbar.contains("SPREAD")); - assert!(!topbar.contains("STREAM")); + assert_eq!(app.overlay, None); + assert_eq!( + app.pending_job, + Some(crate::jobs::JobCommand::DeleteIssue { iid: 101 }) + ); } #[test] - fn palette_uses_premium_marker_and_cursor() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn open_settings_builds_form_from_current_settings() { let mut app = App::demo(); - app.open_palette(); - app.palette_query.push('m'); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("palette render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); + app.settings.gitlab.base_url = "https://gitlab.example.com".into(); + + app.open_settings(); - assert!(screen.contains("m▌")); - assert!(screen.contains("▸")); - assert!(!screen.contains("m_")); - assert!(!screen.contains("> Toggle")); + assert_eq!(app.overlay, Some(Overlay::Settings)); + let form = app.settings_form.as_ref().expect("form"); + assert_eq!(form.draft.gitlab.base_url, "https://gitlab.example.com"); + assert_eq!(form.section, 0); } #[test] - fn board_avoids_ascii_art_status_and_drop_markers() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn save_settings_persists_valid_draft_and_marks_config_dirty() { + let root = unique_temp_root("settings-save"); let mut app = App::demo(); - app.drag = Some(DragState { - source_column: 0, - source_index: 1, - current_x: 42, - current_y: 14, - over: Some(DropTarget::Column { column: 1 }), - moved: true, - }); + app.settings_root = root.clone(); + app.open_settings(); + { + let form = app.settings_form.as_mut().expect("form"); + form.draft.gitlab.base_url = "https://gitlab.example.com".into(); + form.draft.gitlab.project_id = "42".into(); + form.draft.gitlab.token = "glpat-secret".into(); + } - terminal - .draw(|frame| render(frame, &mut app)) - .expect("drag render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); + app.save_settings(); - assert!(!screen.contains(">>>")); - assert!(!screen.contains("<<<")); - assert!(!screen.contains("$")); - assert!(!screen.contains("ctrl+k")); + assert_eq!(app.status_kind, StatusKind::Success); + assert_eq!(app.story_status.as_deref(), Some("Settings saved")); + assert!(app.config_dirty); + assert_eq!(app.settings.gitlab.project_id, "42"); + let reloaded = crate::settings::load_from(&root); + assert_eq!(reloaded.gitlab.base_url, "https://gitlab.example.com"); + assert_eq!(reloaded.gitlab.token, "glpat-secret"); + + let _ = std::fs::remove_dir_all(root); } #[test] - fn topbar_does_not_repeat_lane_labels() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn save_settings_reports_validation_errors_without_persisting() { + let root = unique_temp_root("settings-invalid"); let mut app = App::demo(); + app.settings_root = root.clone(); + app.open_settings(); + { + let form = app.settings_form.as_mut().expect("form"); + form.draft.gitlab.base_url = "not a url".into(); + form.draft.gitlab.project_id = "abc".into(); + } - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let topbar: String = terminal - .backend() - .buffer() - .content() - .iter() - .take(120 * 3) - .map(|cell| cell.symbol()) - .collect(); + app.save_settings(); - assert!(!topbar.contains("TODO")); - assert!(!topbar.contains("IN PROGRESS")); - assert!(!topbar.contains("DONE")); + assert_eq!(app.status_kind, StatusKind::Error); + assert!(!app.config_dirty); + let form = app.settings_form.as_ref().expect("form"); + assert!(!form.errors.is_empty()); + assert!(form.error_for(0, 0).is_some()); + assert!(form.error_for(0, 1).is_some()); + assert!(!crate::settings::settings_path_in(&root).exists()); + + let _ = std::fs::remove_dir_all(root); } #[test] - fn topbar_click_targets_match_visible_controls() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn settings_section_click_switches_tab() { let mut app = App::demo(); + app.open_settings(); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); - let new_issue = char_offset(&screen, "New Issue"); - app.click_at((new_issue % 120) as u16, (new_issue / 120) as u16); - assert_eq!(app.overlay, Some(Overlay::NewIssue)); + app.click(ClickTarget::SettingsSection { index: 2 }); - app.close_overlay(); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); - assert!(!screen.contains("Command")); - assert!(!app - .hitboxes - .iter() - .any(|hitbox| hitbox.target == ClickTarget::Command)); + let form = app.settings_form.as_ref().expect("form"); + assert_eq!(form.section, 2); + assert_eq!(form.focused, 0); } #[test] - fn topbar_legend_labels_are_removed() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn story_preview_toggle_blocks_source_edits() { let mut app = App::demo(); + app.open_generate_stories(); + app.input_story_source_char('a'); + app.toggle_story_preview(); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); + assert_eq!(app.story_tab(), StoryTab::Preview); + app.input_story_source_char('b'); + app.input_story_source_newline(); + app.backspace_story_input(); + assert_eq!(app.story_source, "a"); - assert!(!screen.contains("BOARD VIEW")); - assert!(!screen.contains("DRAG TO MOVE")); - assert!(!screen.contains("CTRL+K COMMANDS")); - assert!(!screen.contains("SORT")); + app.toggle_story_preview(); + assert_eq!(app.story_tab(), StoryTab::Edit); + app.input_story_source_char('b'); + assert_eq!(app.story_source, "ab"); } #[test] - fn labels_and_buttons_use_distinct_visual_styles() { - let label = chip_style(AMBER); - let primary_button = button_primary_style(); - let secondary_button = button_secondary_style(); + fn story_tab_derives_from_focus() { + let mut app = App::demo(); + app.open_generate_stories(); + assert_eq!(app.story_tab(), StoryTab::Edit); - assert_eq!(label.fg, Some(AMBER)); - assert_eq!(label.bg, Some(INK)); - assert_ne!(label.fg, primary_button.fg); - assert_ne!(label.bg, primary_button.bg); - assert_ne!(label.bg, secondary_button.bg); + app.set_generated_stories(vec![crate::model::GeneratedStory { + title: "Draft".into(), + description: "Body".into(), + acceptance_criteria: vec![], + labels: vec![], + priority: None, + size: None, + }]); + app.focus_next_story_field(); + assert_eq!(app.story_tab(), StoryTab::Generated); } #[test] - fn blocked_badge_is_pinned_to_card_top_right() { - let backend = ratatui::backend::TestBackend::new(48, 6); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let blocked = issue( - 902, - "Blocked placement", - LaneState::Todo, - &["ui", "polish"], - true, - ); - - terminal - .draw(|frame| render_card(frame, Rect::new(0, 0, 48, 4), &blocked, false, AMBER)) - .expect("card render"); - let buffer = terminal.backend().buffer(); - let title_row: String = buffer.content()[48..96] - .iter() - .map(|cell| cell.symbol()) - .collect(); - let label_row: String = buffer.content()[96..144] - .iter() - .map(|cell| cell.symbol()) - .collect(); + fn help_overlay_opens_and_scrolls() { + let mut app = App::demo(); + app.open_help(); + assert_eq!(app.overlay, Some(Overlay::Help)); - assert!( - title_row.ends_with(" BLOCKED │"), - "title row: {title_row:?}" - ); - assert!(!label_row.contains("BLOCKED")); + app.scroll_help(4); + assert_eq!(app.help_scroll, 4); + app.scroll_help(-10); + assert_eq!(app.help_scroll, 0); } #[test] - fn right_click_cancels_drag_without_opening_details() { + fn busy_state_drives_loading_tick() { let mut app = App::demo(); - app.add_hitbox( - Rect::new(1, 1, 10, 4), - ClickTarget::Issue { - column: 0, - index: 1, - }, - ); + app.tick_loading(); + assert_eq!(app.loading_tick, 0); - app.mouse_down_at(2, 2); - app.mouse_drag_at(4, 3); - app.right_click_at(4, 3); + app.busy = Some("Syncing".into()); + app.tick_loading(); + app.tick_loading(); + assert_eq!(app.loading_tick, 2); - assert_eq!(app.drag, None); - assert_eq!(app.overlay, None); - assert_eq!(app.selected_issue().iid, 102); + app.busy = None; + app.tick_loading(); + assert_eq!(app.loading_tick, 0); } #[test] - fn modal_renders_premium_form_chrome() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn keyboard_actions_open_close_and_toggle_selected_issue() { let mut app = App::demo(); + app.open_details(); + assert_eq!(app.overlay, Some(Overlay::Details)); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); + app.close_overlay(); + assert_eq!(app.overlay, None); + + app.open_palette(); + assert_eq!(app.overlay, Some(Overlay::Palette)); + + app.open_new_issue(); + assert_eq!(app.overlay, Some(Overlay::NewIssue)); + + app.close_overlay(); + app.toggle_blocked(); + assert!(app.selected_issue().blocked); - assert!(screen.contains("Issue #101")); - assert!(screen.contains("Title")); - assert!(screen.contains("Description")); - assert!(screen.contains("Tab switches field")); + app.toggle_blocked(); + assert!(!app.selected_issue().blocked); } #[test] - fn modal_panel_is_borderless() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn mouse_targets_select_issue_and_activate_buttons() { let mut app = App::demo(); - app.open_details(); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let buffer = terminal.backend().buffer(); - let modal = centered(Rect::new(0, 0, 120, 36), 62, 20); - let top_left = buffer.content()[(modal.y as usize * 120) + modal.x as usize].symbol(); - let top_edge = buffer.content()[(modal.y as usize * 120) + modal.x as usize + 1].symbol(); - let left_edge = - buffer.content()[((modal.y as usize + 1) * 120) + modal.x as usize].symbol(); + app.click(ClickTarget::Issue { + column: 2, + index: 0, + }); + assert_eq!(app.active_column, 2); + assert_eq!(app.selected_issue().iid, 401); + assert_eq!(app.overlay, Some(Overlay::Details)); + + app.click(ClickTarget::Cancel); + assert_eq!(app.overlay, None); + + app.click(ClickTarget::NewIssue); + assert_eq!(app.overlay, Some(Overlay::NewIssue)); + + app.click(ClickTarget::Save); + assert_eq!(app.overlay, None); - assert_ne!(top_left, "+"); - assert_ne!(top_edge, "-"); - assert_ne!(left_edge, "|"); + app.click(ClickTarget::Command); + assert_eq!(app.overlay, Some(Overlay::Palette)); } #[test] - fn renderer_uses_solid_single_line_borders_only() { - let double_border = ["BorderType", "::", "Double"].concat(); + fn open_details_noops_on_empty_board() { + let mut app = App::demo(); + app.replace_issue_records(&[]); + + app.open_details(); - assert!(!include_str!("lib.rs").contains(&double_border)); + assert_eq!(app.overlay, None); } #[test] @@ -4080,119 +2694,23 @@ mod tests { } #[test] - fn render_drag_state_shows_drop_target_and_floating_card() { - let backend = ratatui::backend::TestBackend::new(100, 32); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - app.drag = Some(DragState { - source_column: 0, - source_index: 1, - current_x: 42, - current_y: 14, - over: Some(DropTarget::Column { column: 1 }), - moved: true, - }); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("drag render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); - - assert!(screen.contains("DROP TARGET")); - assert!(screen.contains("DRAGGING #102")); - } - - #[test] - fn modal_buttons_are_compact_one_line_controls() { - let backend = ratatui::backend::TestBackend::new(100, 32); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - app.open_details(); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - - let save = app - .hitboxes - .iter() - .find(|hitbox| hitbox.target == ClickTarget::Save) - .expect("save hitbox") - .rect; - let cancel = app - .hitboxes - .iter() - .find(|hitbox| hitbox.target == ClickTarget::Cancel) - .expect("cancel hitbox") - .rect; - - assert_eq!(save.height, 1); - assert!(save.width <= 14); - assert_eq!(cancel.height, 1); - assert!(cancel.width <= 8); - } - - #[test] - fn modal_disables_background_without_blacking_it_out() { - let backend = ratatui::backend::TestBackend::new(100, 32); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - app.open_details(); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); - - assert!(screen.contains("Issue #101")); - assert!(!screen.contains("VIEWPORT DISABLED")); - assert!(!screen.contains("BACKGROUND DISABLED")); - assert!(app - .hitboxes - .iter() - .all(|hitbox| matches!(hitbox.target, ClickTarget::Save | ClickTarget::Cancel))); - } - - #[test] - fn modal_button_mouse_sequence_closes_without_reopening() { - let backend = ratatui::backend::TestBackend::new(100, 32); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + fn right_click_cancels_drag_without_opening_details() { let mut app = App::demo(); - app.open_details(); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let save = app - .hitboxes - .iter() - .find(|hitbox| hitbox.target == ClickTarget::Save) - .expect("save hitbox") - .rect; - let x = save.x + save.width / 2; - let y = save.y; - - app.mouse_down_at(x, y); - assert_eq!(app.overlay, Some(Overlay::Details)); + app.add_hitbox( + Rect::new(1, 1, 10, 4), + ClickTarget::Issue { + column: 0, + index: 1, + }, + ); - app.mouse_up_at(x, y); - assert_eq!(app.overlay, None); + app.mouse_down_at(2, 2); + app.mouse_drag_at(4, 3); + app.right_click_at(4, 3); - terminal - .draw(|frame| render(frame, &mut app)) - .expect("board render"); + assert_eq!(app.drag, None); assert_eq!(app.overlay, None); + assert_eq!(app.selected_issue().iid, 102); } #[test] @@ -4389,29 +2907,6 @@ mod tests { assert_eq!(app.editor_description_scroll, 8); } - #[test] - fn issue_modal_render_uses_description_scroll_offset() { - let mut app = App::demo(); - app.open_details(); - app.focus_next_editor_field(); - app.editor_description = (0..20) - .map(|index| format!("line {index:02}")) - .collect::>() - .join("\n"); - app.scroll_editor_description(8); - - let screen = render_to_string(120, 36, &mut app); - - assert!( - screen.contains("line 08"), - "screen should show scrolled line" - ); - assert!( - !screen.contains("line 00"), - "screen should hide initial line" - ); - } - #[test] fn saving_details_queues_update_job_when_connected() { let mut app = App::demo(); @@ -4614,63 +3109,6 @@ mod tests { assert_eq!(app.note_input.chars().count(), MAX_DESCRIPTION_CHARS); } - #[test] - fn ai_editor_modal_renders_labels_and_shortcuts() { - let mut app = App::demo(); - app.open_details(); - app.editor_labels = vec!["backend".into()]; - - let screen = render_to_string(120, 36, &mut app); - - assert!(screen.contains("Labels")); - assert!(screen.contains("backend")); - assert!(screen.contains("Ctrl+A Improve")); - assert!(screen.contains("Ctrl+Y Criteria")); - assert!(screen.contains("Ctrl+L Labels")); - } - - #[test] - fn ai_editor_escape_copy_changes_while_ai_in_flight() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - app.open_details(); - app.request_improve_description(); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let modal = centered(Rect::new(0, 0, 120, 36), 62, 20); - let modal_text = region_text(terminal.backend().buffer(), 120, modal); - - assert!(modal_text.contains("Esc")); - assert!(modal_text.contains("Cancel")); - assert!(modal_text.contains("AI")); - } - - #[test] - fn toggle_blocked_queues_job_when_connected() { - let mut app = App::demo(); - app.connected_project = Some(GitLabProject { - id: 42, - path_with_namespace: "demo/owner".into(), - web_url: "https://gitlab.example.com/demo/owner".into(), - }); - - app.toggle_blocked(); - - assert!(!app.selected_issue().blocked); - assert_eq!( - app.pending_job, - Some(crate::jobs::JobCommand::ToggleFlag { - iid: 101, - flag: crate::model::Flag::Blocked, - on: true, - }) - ); - assert_eq!(app.story_status.as_deref(), Some("Blocked toggle pending")); - } - #[test] fn cancelling_modal_keeps_issue_unchanged() { let mut app = App::demo(); @@ -4689,33 +3127,6 @@ mod tests { assert_eq!(app.selected_issue().description, description); } - #[test] - fn modal_renders_editable_description_buffer() { - let backend = ratatui::backend::TestBackend::new(100, 32); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - - app.open_details(); - app.focus_next_editor_field(); - app.clear_editor(); - for ch in "Editable description".chars() { - app.input_editor_char(ch); - } - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); - - assert!(screen.contains("Description")); - assert!(screen.contains("Editable description▌")); - } - #[test] fn description_editor_accepts_multiline_markdown() { let mut app = App::demo(); @@ -4753,42 +3164,4 @@ mod tests { assert_eq!(app.editor_description, "**bold** [text](url)"); } - - #[test] - fn markdown_preview_renders_rich_description() { - let backend = ratatui::backend::TestBackend::new(120, 36); - let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); - let mut app = App::demo(); - app.open_details(); - app.focus_next_editor_field(); - app.clear_editor(); - for ch in "# Heading\n- [x] Done\n`code` and **bold**".chars() { - if ch == '\n' { - app.input_editor_newline(); - } else { - app.input_editor_char(ch); - } - } - app.toggle_markdown_preview(); - - terminal - .draw(|frame| render(frame, &mut app)) - .expect("modal render"); - let screen: String = terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect(); - - assert!(app.editor_preview); - assert!(screen.contains("Preview")); - assert!(screen.contains("HEADING")); - assert!(screen.contains("☑ Done")); - assert!(screen.contains("code")); - assert!(screen.contains("bold")); - assert!(!screen.contains("# Heading")); - assert!(!screen.contains("**bold**")); - } } diff --git a/src/main.rs b/src/main.rs index 475e9ab..6a5b757 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,12 +16,12 @@ use crossterm::{ use lane_ratatui_premium::{ config::{AppConfig, ConfigError, EnvConfig}, gitlab::{GitLabClient, GitLabClientConfig}, - jobs::{self, JobContext, JobResult}, + jobs::{self, JobCommand, JobContext, JobResult}, labels::pick_user_labels, llm::{LlmClient, LlmClientConfig}, render, storage::FileStore, - App, Overlay, + App, Overlay, StatusKind, }; use ratatui::{backend::CrosstermBackend, Terminal}; @@ -48,12 +48,29 @@ fn main() -> io::Result<()> { } fn run(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { - let config = load_config(); + let mut config = load_config(); prepare_startup(&config, app); let mut job_rx: Option> = None; loop { + if app.config_dirty { + app.config_dirty = false; + config = load_config(); + if config.is_ok() { + app.set_status(StatusKind::Info, "Settings applied — reconnecting"); + } + prepare_startup(&config, 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(); @@ -69,6 +86,7 @@ fn run(terminal: &mut Terminal>, app: &mut App) -> } fn prepare_startup(config: &Result, app: &mut App) { + app.settings = lane_ratatui_premium::settings::load(); match config { Ok(config) => { app.set_store(FileStore::for_current_user( @@ -78,13 +96,18 @@ fn prepare_startup(config: &Result, app: &mut App) { app.request_connect(); } Err(err) => { - app.story_status = Some(format!("Config error: {err}")); + app.set_status( + StatusKind::Error, + format!("Config error: {err}. Press Ctrl+, to open Settings."), + ); } } } fn load_config() -> Result { let mut env = EnvConfig::from_env(); + let settings = lane_ratatui_premium::settings::load(); + settings.apply_to_env(&mut env); let Some(instance_url) = env.gitlab_instance_url.clone() else { return AppConfig::from_env_values(env); @@ -135,6 +158,7 @@ fn poll_job_result(job_rx: &mut Option>, app: &mut App) { if clear { *job_rx = None; app.ai_cancelled_worker = false; + app.busy = None; } } @@ -165,6 +189,7 @@ fn maybe_start_job( let Some(request) = jobs::take_pending_job(app) else { return; }; + app.busy = Some(busy_label(&request.command).to_string()); let (tx, rx) = mpsc::channel(); *job_rx = Some(rx); @@ -199,14 +224,44 @@ fn maybe_start_job( }); } +fn busy_label(command: &JobCommand) -> &'static str { + match command { + JobCommand::Connect => "Connecting", + JobCommand::SyncAll => "Syncing", + JobCommand::BootstrapLabels => "Bootstrapping labels", + JobCommand::CreateIssue { .. } => "Creating issue", + JobCommand::UpdateIssue { .. } => "Saving issue", + JobCommand::MoveIssue { .. } => "Moving issue", + JobCommand::ToggleFlag { .. } => "Updating flags", + JobCommand::LoadNotes { .. } => "Loading notes", + JobCommand::CreateNote { .. } => "Adding note", + JobCommand::PullSourceSnapshot { .. } => "Pulling snapshot", + JobCommand::DeleteIssue { .. } => "Deleting issue", + JobCommand::ImproveDescription { .. } => "AI improving description", + JobCommand::GenerateCriteria { .. } => "AI drafting criteria", + JobCommand::SuggestLabels { .. } => "AI suggesting labels", + JobCommand::GenerateStories { .. } => "Generating stories", + JobCommand::CreateSelectedStories { .. } => "Creating stories", + } +} + fn handle_event(event: Event, app: &mut App) { match event { Event::Key(key) if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat => { - match app.overlay { - Some(Overlay::Palette) => handle_palette_key(key.code, key.modifiers, app), - Some(Overlay::GenerateStories) => handle_story_key(key.code, key.modifiers, app), - Some(_) => handle_modal_key(key.code, key.modifiers, app), - None => handle_board_key(key.code, key.modifiers, app), + if app.overlay.is_none() && app.search_active { + handle_search_key(key.code, key.modifiers, app); + } else { + match app.overlay { + Some(Overlay::Palette) => handle_palette_key(key.code, key.modifiers, app), + Some(Overlay::GenerateStories) => { + handle_story_key(key.code, key.modifiers, app) + } + Some(Overlay::Settings) => handle_settings_key(key.code, key.modifiers, app), + Some(Overlay::Help) => handle_help_key(key.code, key.modifiers, app), + Some(Overlay::Confirm) => handle_confirm_key(key.code, key.modifiers, app), + Some(_) => handle_modal_key(key.code, key.modifiers, app), + None => handle_board_key(key.code, key.modifiers, app), + } } } Event::Mouse(mouse) => match mouse.kind { @@ -227,18 +282,47 @@ fn handle_event(event: Event, app: &mut App) { fn handle_board_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { match (code, modifiers) { (KeyCode::Char('q'), _) => app.should_quit = true, - (KeyCode::Esc, _) => app.cancel_drag(), (KeyCode::Char('k'), KeyModifiers::CONTROL) => app.open_palette(), + (KeyCode::Char(','), KeyModifiers::CONTROL) => app.open_settings(), + (KeyCode::Esc, _) => { + if !app.search_query.is_empty() { + app.end_search(false); + } else { + app.cancel_drag(); + } + } (KeyCode::Char('c'), _) => app.request_connect(), (KeyCode::Char('n'), _) => app.open_new_issue(), (KeyCode::Char('g'), _) => app.open_generate_stories(), (KeyCode::Char('r'), _) => app.request_sync_all(), + (KeyCode::Char('s'), _) => app.request_sync_all(), (KeyCode::Char('b'), _) => app.toggle_blocked(), + (KeyCode::Char('e'), _) => app.open_details(), + (KeyCode::Char('/'), _) => app.start_search(), + (KeyCode::Char('?'), _) => app.open_help(), (KeyCode::Enter, _) => app.open_details(), (KeyCode::Right, _) => app.move_column_right(), (KeyCode::Left, _) => app.move_column_left(), (KeyCode::Down, _) => app.move_issue_down(), (KeyCode::Up, _) => app.move_issue_up(), + (KeyCode::Char('h'), _) => app.move_column_left(), + (KeyCode::Char('l'), _) => app.move_column_right(), + (KeyCode::Char('j'), _) => app.move_issue_down(), + (KeyCode::Char('k'), _) => app.move_issue_up(), + _ => {} + } +} + +fn handle_search_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { + match (code, modifiers) { + (KeyCode::Esc, _) => app.end_search(false), + (KeyCode::Enter, _) => app.end_search(true), + (KeyCode::Backspace, _) => app.backspace_search(), + (KeyCode::Down, _) => app.move_issue_down(), + (KeyCode::Up, _) => app.move_issue_up(), + (KeyCode::Char(ch), KeyModifiers::NONE) | (KeyCode::Char(ch), KeyModifiers::SHIFT) => { + app.input_search_char(ch) + } _ => {} } } @@ -332,6 +416,9 @@ fn handle_story_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { (KeyCode::Down, _) => app.move_story_selection_down(), (KeyCode::Char(' '), _) => app.toggle_selected_story(), (KeyCode::Backspace, _) => app.backspace_story_input(), + (KeyCode::Char('p'), KeyModifiers::CONTROL) => app.toggle_story_preview(), + (KeyCode::PageDown, _) => app.scroll_story_source(4), + (KeyCode::PageUp, _) => app.scroll_story_source(-4), (KeyCode::Char('g'), KeyModifiers::CONTROL) => { let labels = app .project_labels @@ -377,6 +464,95 @@ fn handle_palette_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { } } +fn handle_settings_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { + match (code, modifiers) { + (KeyCode::Esc, _) => app.close_overlay(), + (KeyCode::Char('s'), KeyModifiers::CONTROL) | (KeyCode::Enter, KeyModifiers::CONTROL) => { + app.save_settings() + } + (KeyCode::Tab, _) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.focus_next(); + } + (KeyCode::BackTab, _) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.focus_prev(); + } + (KeyCode::Down, _) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.focus_next(); + } + (KeyCode::Up, _) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.focus_prev(); + } + (KeyCode::Left, _) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.prev_section(); + } + (KeyCode::Right, _) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.next_section(); + } + (KeyCode::Char(' '), KeyModifiers::NONE) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + if form.field_is_toggle(form.focused) { + form.toggle(); + } else { + form.input_char(' '); + } + } + (KeyCode::Backspace, _) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.backspace(); + } + (KeyCode::Char(ch), KeyModifiers::NONE) | (KeyCode::Char(ch), KeyModifiers::SHIFT) => { + let Some(form) = app.settings_form.as_mut() else { + return; + }; + form.input_char(ch); + } + _ => {} + } +} + +fn handle_help_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { + match (code, modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) | (KeyCode::Char('?'), _) => { + app.close_overlay() + } + (KeyCode::Down, _) => app.scroll_help(1), + (KeyCode::Up, _) => app.scroll_help(-1), + (KeyCode::PageDown, _) => app.scroll_help(6), + (KeyCode::PageUp, _) => app.scroll_help(-6), + _ => {} + } +} + +fn handle_confirm_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { + match (code, modifiers) { + (KeyCode::Enter, _) | (KeyCode::Char('y'), _) => app.confirm_accept(), + (KeyCode::Esc, _) | (KeyCode::Char('n'), _) => app.confirm_cancel(), + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -764,8 +940,8 @@ mod tests { handle_story_key(KeyCode::Char(' '), KeyModifiers::NONE, &mut app); handle_story_key(KeyCode::Char('s'), KeyModifiers::CONTROL, &mut app); - assert_eq!(app.generated_stories[0].selected, true); - assert_eq!(app.generated_stories[1].selected, false); + assert!(app.generated_stories[0].selected); + assert!(!app.generated_stories[1].selected); assert_eq!( app.pending_job, Some(JobCommand::CreateSelectedStories { @@ -820,4 +996,162 @@ mod tests { assert_eq!(app.pending_job, None); } + + #[test] + fn board_s_shortcut_requests_sync_all() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char('s'), KeyModifiers::NONE, &mut app); + + assert_eq!(app.pending_job, Some(JobCommand::SyncAll)); + assert_eq!(app.story_status.as_deref(), Some("Sync all pending")); + } + + #[test] + fn slash_activates_search_and_search_key_routes_chars_not_quit() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char('/'), KeyModifiers::NONE, &mut app); + assert!(app.search_active); + + handle_search_key(KeyCode::Char('q'), KeyModifiers::NONE, &mut app); + + assert_eq!(app.search_query, "q"); + assert!(!app.should_quit); + + handle_search_key(KeyCode::Esc, KeyModifiers::NONE, &mut app); + + assert!(!app.search_active); + assert_eq!(app.search_query, ""); + } + + #[test] + fn board_question_mark_opens_help() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char('?'), KeyModifiers::SHIFT, &mut app); + + assert_eq!(app.overlay, Some(Overlay::Help)); + } + + #[test] + fn ctrl_comma_opens_settings_with_form() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char(','), KeyModifiers::CONTROL, &mut app); + + assert_eq!(app.overlay, Some(Overlay::Settings)); + assert!(app.settings_form.is_some()); + } + + #[test] + fn board_e_shortcut_opens_details() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char('e'), KeyModifiers::NONE, &mut app); + + assert_eq!(app.overlay, Some(Overlay::Details)); + } + + #[test] + fn board_vim_style_keys_move_columns_and_selection() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char('l'), KeyModifiers::NONE, &mut app); + assert_eq!(app.active_column, 1); + + handle_board_key(KeyCode::Char('h'), KeyModifiers::NONE, &mut app); + assert_eq!(app.active_column, 0); + + handle_board_key(KeyCode::Char('j'), KeyModifiers::NONE, &mut app); + assert_eq!(app.selected[0], 1); + + handle_board_key(KeyCode::Char('k'), KeyModifiers::NONE, &mut app); + assert_eq!(app.selected[0], 0); + } + + #[test] + fn confirm_key_y_accepts_pending_delete_and_removes_issue() { + let mut app = App::demo(); + app.request_delete_issue(); + assert_eq!(app.overlay, Some(Overlay::Confirm)); + + handle_confirm_key(KeyCode::Char('y'), KeyModifiers::NONE, &mut app); + + assert_eq!(app.overlay, None); + assert!(app.confirm.is_none()); + assert!(!app.columns[0].iter().any(|issue| issue.iid == 101)); + } + + #[test] + fn confirm_key_n_cancels_pending_delete_and_keeps_issue() { + let mut app = App::demo(); + app.request_delete_issue(); + assert_eq!(app.overlay, Some(Overlay::Confirm)); + + handle_confirm_key(KeyCode::Char('n'), KeyModifiers::NONE, &mut app); + + assert_eq!(app.overlay, None); + assert!(app.confirm.is_none()); + assert!(app.columns[0].iter().any(|issue| issue.iid == 101)); + assert_eq!(app.story_status.as_deref(), Some("Cancelled")); + } + + #[test] + fn settings_key_handler_edits_field_toggles_and_saves() { + let mut app = App::demo(); + app.settings_root = + std::env::temp_dir().join(format!("lane-main-test-{}", std::process::id())); + app.open_settings(); + + // Move focus to "Default Labels" (a validation-free text field) and type into it. + handle_settings_key(KeyCode::Down, KeyModifiers::NONE, &mut app); + handle_settings_key(KeyCode::Down, KeyModifiers::NONE, &mut app); + handle_settings_key(KeyCode::Down, KeyModifiers::NONE, &mut app); + handle_settings_key(KeyCode::Char('u'), KeyModifiers::NONE, &mut app); + handle_settings_key(KeyCode::Char('x'), KeyModifiers::NONE, &mut app); + { + let form = app.settings_form.as_ref().expect("form"); + assert_eq!(form.section, 0); + assert_eq!(form.focused, 3); + assert_eq!(form.field_value(3), "ux"); + } + + // Move to the Appearance section and toggle the focused (first) field. + handle_settings_key(KeyCode::Right, KeyModifiers::NONE, &mut app); + handle_settings_key(KeyCode::Right, KeyModifiers::NONE, &mut app); + handle_settings_key(KeyCode::Char(' '), KeyModifiers::NONE, &mut app); + { + let form = app.settings_form.as_ref().expect("form"); + assert_eq!(form.section, 2); + assert!(form.draft.appearance.compact); + } + + handle_settings_key(KeyCode::Char('s'), KeyModifiers::CONTROL, &mut app); + + assert_eq!(app.story_status.as_deref(), Some("Settings saved")); + assert!(app.settings.appearance.compact); + assert_eq!(app.settings.gitlab.default_labels, "ux"); + + let _ = std::fs::remove_dir_all(&app.settings_root); + } + + #[test] + fn busy_label_maps_sync_all_to_syncing() { + assert_eq!(busy_label(&JobCommand::SyncAll), "Syncing"); + } + + #[test] + fn poll_job_result_clears_busy_on_disconnect() { + let mut app = App::demo(); + app.busy = Some("Syncing".to_string()); + let (tx, rx) = std::sync::mpsc::channel::(); + drop(tx); + let mut job_rx = Some(rx); + + poll_job_result(&mut job_rx, &mut app); + + assert!(job_rx.is_none()); + assert_eq!(app.busy, None); + } } diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..544640b --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,603 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::config::EnvConfig; +use crate::storage::{default_root, read_json, write_json, StorageError}; + +fn default_true() -> bool { + true +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + pub gitlab: GitLabSettings, + pub ai: AiSettings, + pub appearance: AppearanceSettings, + pub advanced: AdvancedSettings, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct GitLabSettings { + pub base_url: String, + pub project_id: String, + pub token: String, + pub default_labels: String, + pub sync_on_startup: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AiSettings { + pub base_url: String, + pub api_key: String, + pub model: String, + pub temperature: String, + pub enabled: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AppearanceSettings { + pub compact: bool, + #[serde(default = "default_true")] + pub show_labels: bool, + #[serde(default = "default_true")] + pub show_ids: bool, +} + +impl Default for AppearanceSettings { + fn default() -> Self { + Self { + compact: false, + show_labels: true, + show_ids: true, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdvancedSettings { + #[serde(default = "default_true")] + pub confirm_destructive: bool, + pub debug: bool, +} + +impl Default for AdvancedSettings { + fn default() -> Self { + Self { + confirm_destructive: true, + debug: false, + } + } +} + +pub fn settings_path_in(root: &Path) -> PathBuf { + root.join("settings.json") +} + +pub fn load_from(root: &Path) -> Settings { + read_json(&settings_path_in(root)).unwrap_or_default() +} + +pub fn load() -> Settings { + load_from(&default_root()) +} + +impl Settings { + pub fn save_to(&self, root: &Path) -> Result<(), StorageError> { + write_json(&settings_path_in(root), self) + } + + pub fn save(&self) -> Result<(), StorageError> { + self.save_to(&default_root()) + } + + pub fn apply_to_env(&self, env: &mut EnvConfig) { + if env.gitlab_instance_url.is_none() && !self.gitlab.base_url.is_empty() { + env.gitlab_instance_url = Some(self.gitlab.base_url.clone()); + } + if env.gitlab_token.is_none() && !self.gitlab.token.is_empty() { + env.gitlab_token = Some(self.gitlab.token.clone()); + } + if env.personal_project_id.is_none() && !self.gitlab.project_id.is_empty() { + env.personal_project_id = Some(self.gitlab.project_id.clone()); + } + if env.llm_base_url.is_none() && !self.ai.base_url.is_empty() { + env.llm_base_url = Some(self.ai.base_url.clone()); + } + if env.llm_api_key.is_none() && !self.ai.api_key.is_empty() { + env.llm_api_key = Some(self.ai.api_key.clone()); + } + if env.llm_model.is_none() && !self.ai.model.is_empty() { + env.llm_model = Some(self.ai.model.clone()); + } + if env.llm_temperature.is_none() && !self.ai.temperature.is_empty() { + env.llm_temperature = Some(self.ai.temperature.clone()); + } + if env.llm_enabled.is_none() { + env.llm_enabled = Some(if self.ai.enabled { "true" } else { "false" }.to_string()); + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FieldError { + pub section: usize, + pub field: usize, + pub message: String, +} + +pub fn validate(settings: &Settings) -> Vec { + let mut errors = Vec::new(); + + if !settings.gitlab.base_url.is_empty() && !is_http_url(&settings.gitlab.base_url) { + errors.push(FieldError { + section: 0, + field: 0, + message: "must be an http(s) URL".to_string(), + }); + } + if !settings.gitlab.project_id.is_empty() && settings.gitlab.project_id.parse::().is_err() + { + errors.push(FieldError { + section: 0, + field: 1, + message: "must be a number".to_string(), + }); + } + if !settings.ai.base_url.is_empty() && !is_http_url(&settings.ai.base_url) { + errors.push(FieldError { + section: 1, + field: 0, + message: "must be an http(s) URL".to_string(), + }); + } + if !settings.ai.temperature.is_empty() { + let in_range = settings + .ai + .temperature + .parse::() + .map(|value| (0.0..=2.0).contains(&value)) + .unwrap_or(false); + if !in_range { + errors.push(FieldError { + section: 1, + field: 3, + message: "must be a number between 0.0 and 2.0".to_string(), + }); + } + } + + errors +} + +fn is_http_url(value: &str) -> bool { + Url::parse(value) + .map(|url| matches!(url.scheme(), "http" | "https")) + .unwrap_or(false) +} + +pub const SECTION_TITLES: [&str; 4] = ["GitLab", "AI", "Appearance", "Advanced"]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FieldKind { + Text, + Secret, + Toggle, +} + +struct FieldSpec { + label: &'static str, + kind: FieldKind, +} + +const GITLAB_FIELDS: [FieldSpec; 5] = [ + FieldSpec { + label: "Base URL", + kind: FieldKind::Text, + }, + FieldSpec { + label: "Project ID", + kind: FieldKind::Text, + }, + FieldSpec { + label: "Access Token", + kind: FieldKind::Secret, + }, + FieldSpec { + label: "Default Labels", + kind: FieldKind::Text, + }, + FieldSpec { + label: "Sync on startup", + kind: FieldKind::Toggle, + }, +]; + +const AI_FIELDS: [FieldSpec; 5] = [ + FieldSpec { + label: "Base URL", + kind: FieldKind::Text, + }, + FieldSpec { + label: "API Key", + kind: FieldKind::Secret, + }, + FieldSpec { + label: "Model", + kind: FieldKind::Text, + }, + FieldSpec { + label: "Temperature", + kind: FieldKind::Text, + }, + FieldSpec { + label: "Enabled", + kind: FieldKind::Toggle, + }, +]; + +const APPEARANCE_FIELDS: [FieldSpec; 3] = [ + FieldSpec { + label: "Compact density", + kind: FieldKind::Toggle, + }, + FieldSpec { + label: "Show labels", + kind: FieldKind::Toggle, + }, + FieldSpec { + label: "Show issue IDs", + kind: FieldKind::Toggle, + }, +]; + +const ADVANCED_FIELDS: [FieldSpec; 2] = [ + FieldSpec { + label: "Confirm destructive actions", + kind: FieldKind::Toggle, + }, + FieldSpec { + label: "Debug logging", + kind: FieldKind::Toggle, + }, +]; + +fn fields_for(section: usize) -> &'static [FieldSpec] { + match section { + 0 => &GITLAB_FIELDS, + 1 => &AI_FIELDS, + 2 => &APPEARANCE_FIELDS, + _ => &ADVANCED_FIELDS, + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettingsForm { + pub draft: Settings, + pub section: usize, + pub focused: usize, + pub errors: Vec, + pub feedback: Option>, +} + +impl SettingsForm { + pub fn new(settings: Settings) -> Self { + Self { + draft: settings, + section: 0, + focused: 0, + errors: Vec::new(), + feedback: None, + } + } + + pub fn field_count(&self) -> usize { + fields_for(self.section).len() + } + + pub fn field_label(&self, index: usize) -> &'static str { + fields_for(self.section)[index].label + } + + pub fn field_is_secret(&self, index: usize) -> bool { + fields_for(self.section)[index].kind == FieldKind::Secret + } + + pub fn field_is_toggle(&self, index: usize) -> bool { + fields_for(self.section)[index].kind == FieldKind::Toggle + } + + pub fn field_value(&self, index: usize) -> String { + match (self.section, index) { + (0, 0) => self.draft.gitlab.base_url.clone(), + (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(), + (1, 3) => self.draft.ai.temperature.clone(), + (1, 4) => toggle_str(self.draft.ai.enabled), + (2, 0) => toggle_str(self.draft.appearance.compact), + (2, 1) => toggle_str(self.draft.appearance.show_labels), + (2, 2) => toggle_str(self.draft.appearance.show_ids), + (3, 0) => toggle_str(self.draft.advanced.confirm_destructive), + (3, 1) => toggle_str(self.draft.advanced.debug), + _ => String::new(), + } + } + + pub fn next_section(&mut self) { + self.section = (self.section + 1) % SECTION_TITLES.len(); + self.focused = 0; + self.feedback = None; + } + + pub fn prev_section(&mut self) { + self.section = (self.section + SECTION_TITLES.len() - 1) % SECTION_TITLES.len(); + self.focused = 0; + self.feedback = None; + } + + pub fn focus_next(&mut self) { + let count = self.field_count(); + if count == 0 { + return; + } + self.focused = (self.focused + 1) % count; + } + + pub fn focus_prev(&mut self) { + let count = self.field_count(); + if count == 0 { + return; + } + self.focused = (self.focused + count - 1) % count; + } + + pub fn input_char(&mut self, ch: char) { + if ch.is_control() || self.field_is_toggle(self.focused) { + return; + } + let section = self.section; + let focused = self.focused; + if let Some(value) = self.text_field_mut(section, focused) { + if value.chars().count() < 200 { + value.push(ch); + } + } + } + + pub fn backspace(&mut self) { + if self.field_is_toggle(self.focused) { + return; + } + let section = self.section; + let focused = self.focused; + if let Some(value) = self.text_field_mut(section, focused) { + value.pop(); + } + } + + pub fn toggle(&mut self) { + if !self.field_is_toggle(self.focused) { + 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, + (2, 2) => self.draft.appearance.show_ids = !self.draft.appearance.show_ids, + (3, 0) => { + self.draft.advanced.confirm_destructive = !self.draft.advanced.confirm_destructive + } + (3, 1) => self.draft.advanced.debug = !self.draft.advanced.debug, + _ => {} + } + } + + pub fn error_for(&self, section: usize, field: usize) -> Option<&str> { + self.errors + .iter() + .find(|error| error.section == section && error.field == field) + .map(|error| error.message.as_str()) + } + + fn text_field_mut(&mut self, section: usize, field: usize) -> Option<&mut String> { + match (section, field) { + (0, 0) => Some(&mut self.draft.gitlab.base_url), + (0, 1) => Some(&mut self.draft.gitlab.project_id), + (0, 2) => Some(&mut self.draft.gitlab.token), + (0, 3) => Some(&mut self.draft.gitlab.default_labels), + (1, 0) => Some(&mut self.draft.ai.base_url), + (1, 1) => Some(&mut self.draft.ai.api_key), + (1, 2) => Some(&mut self.draft.ai.model), + (1, 3) => Some(&mut self.draft.ai.temperature), + _ => None, + } + } +} + +fn toggle_str(value: bool) -> String { + if value { "on" } else { "off" }.to_string() +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + fn unique_suffix() -> String { + let since_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + format!("{}-{since_epoch}", std::process::id()) + } + + #[test] + fn save_to_and_load_from_roundtrip() { + let root = std::env::temp_dir().join(format!("lane-settings-test-{}", unique_suffix())); + + let mut settings = Settings::default(); + settings.gitlab.base_url = "https://gitlab.example.com".to_string(); + settings.gitlab.project_id = "42".to_string(); + settings.ai.enabled = true; + + settings.save_to(&root).unwrap(); + let loaded = load_from(&root); + + assert_eq!(loaded, settings); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn load_from_missing_path_returns_defaults() { + let root = std::env::temp_dir().join(format!("lane-settings-test-{}", unique_suffix())); + + let settings = load_from(&root); + + assert_eq!(settings, Settings::default()); + assert!(settings.appearance.show_labels); + assert!(settings.appearance.show_ids); + assert!(settings.advanced.confirm_destructive); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn deserializing_empty_json_yields_defaults() { + let settings: Settings = serde_json::from_str("{}").unwrap(); + + assert_eq!(settings, Settings::default()); + assert!(settings.appearance.show_labels); + assert!(settings.appearance.show_ids); + assert!(settings.advanced.confirm_destructive); + } + + #[test] + fn validate_flags_bad_fields_with_correct_indices() { + let mut settings = Settings::default(); + settings.gitlab.base_url = "not-a-url".to_string(); + settings.gitlab.project_id = "abc".to_string(); + settings.ai.temperature = "5".to_string(); + + let errors = validate(&settings); + + assert!(errors.contains(&FieldError { + section: 0, + field: 0, + message: "must be an http(s) URL".to_string(), + })); + assert!(errors.contains(&FieldError { + section: 0, + field: 1, + message: "must be a number".to_string(), + })); + assert!(errors.contains(&FieldError { + section: 1, + field: 3, + message: "must be a number between 0.0 and 2.0".to_string(), + })); + } + + #[test] + fn validate_empty_settings_has_no_errors() { + assert!(validate(&Settings::default()).is_empty()); + } + + #[test] + fn apply_to_env_fills_only_missing_fields_and_env_wins() { + let mut settings = Settings::default(); + settings.gitlab.base_url = "https://gitlab.example.com".to_string(); + settings.gitlab.token = "glpat-secret".to_string(); + settings.gitlab.project_id = "42".to_string(); + settings.ai.base_url = "https://ai.example.com".to_string(); + settings.ai.api_key = "ai-secret".to_string(); + settings.ai.model = "gpt".to_string(); + settings.ai.temperature = "0.7".to_string(); + settings.ai.enabled = true; + + let mut env = EnvConfig { + gitlab_instance_url: Some("https://env-wins.example.com".to_string()), + ..EnvConfig::default() + }; + + settings.apply_to_env(&mut env); + + assert_eq!( + env.gitlab_instance_url, + Some("https://env-wins.example.com".to_string()) + ); + assert_eq!(env.gitlab_token, Some("glpat-secret".to_string())); + assert_eq!(env.personal_project_id, Some("42".to_string())); + assert_eq!(env.llm_base_url, Some("https://ai.example.com".to_string())); + assert_eq!(env.llm_api_key, Some("ai-secret".to_string())); + assert_eq!(env.llm_model, Some("gpt".to_string())); + assert_eq!(env.llm_temperature, Some("0.7".to_string())); + assert_eq!(env.llm_enabled, Some("true".to_string())); + } + + #[test] + fn form_wraps_sections_and_fields() { + let mut form = SettingsForm::new(Settings::default()); + + form.prev_section(); + assert_eq!(form.section, SECTION_TITLES.len() - 1); + assert_eq!(form.focused, 0); + + form.next_section(); + assert_eq!(form.section, 0); + + form.section = 3; + form.focused = 0; + form.focus_prev(); + assert_eq!(form.focused, ADVANCED_FIELDS.len() - 1); + + form.focus_next(); + assert_eq!(form.focused, 0); + } + + #[test] + fn form_input_and_toggle_behavior() { + let mut form = SettingsForm::new(Settings::default()); + + form.section = 0; + form.focused = 4; // Sync on startup toggle + form.input_char('x'); + assert_eq!(form.field_value(4), "off"); + + form.toggle(); + assert_eq!(form.field_value(4), "on"); + + form.focused = 0; // Base URL text field + form.input_char('h'); + form.input_char('i'); + assert_eq!(form.field_value(0), "hi"); + form.backspace(); + assert_eq!(form.field_value(0), "h"); + } + + #[test] + fn secret_fields_are_flagged_on_gitlab_token_and_ai_key() { + let mut form = SettingsForm::new(Settings::default()); + + form.section = 0; + assert!(form.field_is_secret(2)); + assert!(!form.field_is_secret(0)); + + form.section = 1; + assert!(form.field_is_secret(1)); + assert!(!form.field_is_secret(2)); + } +} diff --git a/src/storage.rs b/src/storage.rs index f9195a9..f990d11 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -149,7 +149,10 @@ pub fn default_root() -> PathBuf { PathBuf::from(".") } -fn write_json(path: &Path, value: &T) -> Result<(), StorageError> { +pub(crate) fn write_json( + path: &Path, + value: &T, +) -> Result<(), StorageError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -159,7 +162,7 @@ fn write_json(path: &Path, value: &T) -> Result<(), Stora Ok(()) } -fn read_json(path: &Path) -> Result { +pub(crate) fn read_json(path: &Path) -> Result { let text = match fs::read_to_string(path) { Ok(text) => text, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..f05e68c --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,143 @@ +//! Centralized theme for the Lane TUI. +//! +//! Every render module pulls colors and shared styles from here so the +//! product has one visual voice. Color is used sparingly: hierarchy comes +//! from spacing, borders, and focus states, not saturation. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::symbols::border; + +// Base surfaces. +pub const BG: Color = Color::Rgb(0x0B, 0x11, 0x17); +pub const SURFACE: Color = Color::Rgb(0x11, 0x18, 0x27); +pub const SURFACE_ELEVATED: Color = Color::Rgb(0x16, 0x22, 0x31); + +// Lines. +pub const BORDER: Color = Color::Rgb(0x26, 0x34, 0x45); +pub const BORDER_FOCUS: Color = Color::Rgb(0x7C, 0x5C, 0xFF); + +// Text. +pub const TEXT: Color = Color::Rgb(0xE5, 0xE7, 0xEB); +pub const TEXT_SECONDARY: Color = Color::Rgb(0xA7, 0xB0, 0xC0); +pub const TEXT_MUTED: Color = Color::Rgb(0x64, 0x74, 0x8B); + +// Accents. +pub const ACCENT: Color = Color::Rgb(0x7C, 0x5C, 0xFF); +pub const ACCENT_SECONDARY: Color = Color::Rgb(0x3B, 0x82, 0xF6); + +// Semantic. +pub const SUCCESS: Color = Color::Rgb(0x22, 0xC5, 0x5E); +pub const WARNING: Color = Color::Rgb(0xF5, 0x9E, 0x0B); +pub const ERROR: Color = Color::Rgb(0xEF, 0x44, 0x44); +pub const INFO: Color = Color::Rgb(0x38, 0xBD, 0xF8); + +/// Spinner frames for in-flight async work. +pub const SPINNER_FRAMES: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"]; + +pub fn spinner_frame(tick: u16) -> &'static str { + SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()] +} + +/// Single-line solid border set used everywhere (no doubles, no ASCII). +pub fn solid_border() -> border::Set<'static> { + border::Set { + top_left: "┌", + top_right: "┐", + bottom_left: "└", + bottom_right: "┘", + vertical_left: "│", + vertical_right: "│", + horizontal_top: "─", + horizontal_bottom: "─", + } +} + +pub fn button_primary_style() -> Style { + Style::default() + .fg(Color::Black) + .bg(ACCENT) + .add_modifier(Modifier::BOLD) +} + +pub fn button_secondary_style() -> Style { + Style::default().fg(TEXT_SECONDARY).bg(SURFACE_ELEVATED) +} + +pub fn button_danger_style() -> Style { + Style::default() + .fg(Color::Black) + .bg(ERROR) + .add_modifier(Modifier::BOLD) +} + +/// Subtle label pill on cards and forms. +pub fn chip_style(accent: Color) -> Style { + Style::default().fg(accent).bg(SURFACE_ELEVATED) +} + +pub fn keycap_style() -> Style { + Style::default() + .fg(TEXT) + .bg(SURFACE_ELEVATED) + .add_modifier(Modifier::BOLD) +} + +pub fn blocked_style() -> Style { + Style::default() + .fg(Color::Black) + .bg(ERROR) + .add_modifier(Modifier::BOLD) +} + +pub fn code_style() -> Style { + Style::default().fg(TEXT).bg(BG) +} + +/// Text-input value styling: focused fields glow, idle fields recede. +pub fn editor_value_style(active: bool) -> Style { + if active { + Style::default().fg(TEXT).bg(SURFACE_ELEVATED) + } else { + Style::default().fg(TEXT_SECONDARY).bg(SURFACE) + } +} + +pub fn status_color(kind: crate::StatusKind) -> Color { + match kind { + crate::StatusKind::Info => TEXT_SECONDARY, + crate::StatusKind::Success => SUCCESS, + crate::StatusKind::Warning => WARNING, + crate::StatusKind::Error => ERROR, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn palette_matches_spec() { + assert_eq!(BG, Color::Rgb(0x0B, 0x11, 0x17)); + assert_eq!(SURFACE, Color::Rgb(0x11, 0x18, 0x27)); + assert_eq!(SURFACE_ELEVATED, Color::Rgb(0x16, 0x22, 0x31)); + assert_eq!(BORDER, Color::Rgb(0x26, 0x34, 0x45)); + assert_eq!(ACCENT, Color::Rgb(0x7C, 0x5C, 0xFF)); + assert_eq!(BORDER_FOCUS, ACCENT); + assert_eq!(SUCCESS, Color::Rgb(0x22, 0xC5, 0x5E)); + assert_eq!(ERROR, Color::Rgb(0xEF, 0x44, 0x44)); + } + + #[test] + fn spinner_wraps_by_tick() { + assert_eq!(spinner_frame(0), SPINNER_FRAMES[0]); + assert_eq!(spinner_frame(8), SPINNER_FRAMES[0]); + assert_eq!(spinner_frame(3), SPINNER_FRAMES[3]); + } + + #[test] + fn buttons_and_chips_are_visually_distinct() { + assert_ne!(chip_style(ACCENT).bg, button_primary_style().bg); + assert_ne!(button_primary_style().bg, button_secondary_style().bg); + assert_ne!(button_primary_style().bg, button_danger_style().bg); + } +} diff --git a/src/ui/board.rs b/src/ui/board.rs new file mode 100644 index 0000000..5ed9ce4 --- /dev/null +++ b/src/ui/board.rs @@ -0,0 +1,336 @@ +//! The three-column kanban board: columns, cards, drag affordances. + +use ratatui::{ + prelude::*, + widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, +}; + +use crate::settings::AppearanceSettings; +use crate::theme; +use crate::{App, ClickTarget, DropTarget, LaneState}; + +pub(crate) fn state_color(state: LaneState) -> Color { + match state { + LaneState::Todo => theme::ACCENT_SECONDARY, + LaneState::Doing => theme::WARNING, + LaneState::Done => theme::SUCCESS, + } +} + +fn drag_over_column(app: &App, column: usize) -> bool { + matches!( + app.drag.as_ref().and_then(|drag| drag.over), + Some(DropTarget::Column { column: over_column }) if over_column == column + ) || matches!( + app.drag.as_ref().and_then(|drag| drag.over), + Some(DropTarget::Issue { column: over_column, .. }) if over_column == column + ) +} + +pub fn render_board(frame: &mut Frame, area: Rect, app: &mut App) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(area); + + for column_index in 0..columns.len().min(app.columns.len()) { + render_column(frame, columns[column_index], app, column_index); + } +} + +/// Centered muted placeholder shown when a column has nothing to display. +fn render_empty_state(frame: &mut Frame, area: Rect, lines: &[&str]) { + let height = lines.len() as u16; + if area.height < height || area.width == 0 { + return; + } + let text: Vec = lines + .iter() + .map(|line| Line::from(Span::styled(*line, Style::default().fg(theme::TEXT_MUTED)))) + .collect(); + let y = area.y + area.height.saturating_sub(height) / 2; + frame.render_widget( + Paragraph::new(text).alignment(Alignment::Center), + Rect::new(area.x, y, area.width, height), + ); +} + +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 + }; + let block = Block::new() + .title(format!( + " {} {:02} ", + state.title(), + app.columns[column_index].len() + )) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(theme::BG)); + let inner = block.inner(area); + frame.render_widget(block, area); + app.add_hitbox( + inner, + ClickTarget::Column { + column: column_index, + }, + ); + + let rows = app.visible_rows(column_index); + if app.columns[column_index].is_empty() { + render_empty_state(frame, inner, &["No issues", "Press n to create"]); + } else if rows.is_empty() { + render_empty_state(frame, inner, &["No matches"]); + } else { + let card_height: u16 = if app.settings.appearance.compact { + 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]; + + for (visible_index, &absolute_index) in + rows.iter().skip(scroll).take(visible + 1).enumerate() + { + let y = inner.y + visible_index as u16 * stride; + if y + card_height > inner.y + inner.height { + break; + } + 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 + && drag.source_column == column_index + && drag.source_index == absolute_index + }) { + render_lift_placeholder(frame, rect); + continue; + } + let is_selected = active && selected == absolute_index; + let issue = &app.columns[column_index][absolute_index]; + render_card( + frame, + rect, + issue, + is_selected, + accent, + &app.settings.appearance, + ); + app.add_hitbox( + rect, + ClickTarget::Issue { + column: column_index, + index: absolute_index, + }, + ); + } + } + + if drop_active && inner.width > 4 { + render_drop_target( + frame, + Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(3), 1), + ); + } +} + +pub(crate) fn render_card( + frame: &mut Frame, + area: Rect, + issue: &crate::Issue, + selected: bool, + accent: Color, + appearance: &AppearanceSettings, +) { + let border_color = if selected { + theme::BORDER_FOCUS + } else { + theme::BORDER + }; + let bg = if selected { + theme::SURFACE_ELEVATED + } else { + theme::SURFACE + }; + let block = Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(bg)); + let inner = block.inner(area); + frame.render_widget(block, area); + if area.width > 3 { + frame.render_widget( + Paragraph::new(" ").style(Style::default().bg(if issue.blocked { + theme::ERROR + } else { + accent + })), + Rect::new(area.x + 1, area.y + 1, 1, area.height.saturating_sub(2)), + ); + } + + let mut lines = Vec::new(); + let mut title_spans = Vec::new(); + if appearance.show_ids { + title_spans.push(Span::styled( + format!("#{} ", issue.iid), + Style::default().fg(accent).add_modifier(Modifier::BOLD), + )); + } + title_spans.push(Span::styled( + issue.title.as_str(), + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); + lines.push(Line::from(title_spans)); + + if appearance.show_labels && !appearance.compact { + let mut label_spans = Vec::new(); + for (index, label) in issue.labels.iter().enumerate() { + if index > 0 { + label_spans.push(Span::raw(" ")); + } + label_spans.push(Span::styled( + format!(" {label} "), + theme::chip_style(theme::TEXT_SECONDARY), + )); + } + lines.push(Line::from(label_spans)); + } + + if !appearance.compact { + lines.push(Line::from(vec![ + Span::styled("STATE ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(issue.state.title(), Style::default().fg(accent)), + ])); + } + + let content = Rect::new( + inner.x + 2, + inner.y, + inner.width.saturating_sub(2), + inner.height, + ); + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: true }), + content, + ); + if issue.blocked && area.width > 14 { + frame.render_widget( + Paragraph::new(" BLOCKED ") + .alignment(Alignment::Center) + .style(theme::blocked_style()), + Rect::new(area.x + area.width.saturating_sub(10), inner.y, 9, 1), + ); + } +} + +fn render_drop_target(frame: &mut Frame, area: Rect) { + frame.render_widget( + Paragraph::new(" DROP TARGET ") + .alignment(Alignment::Center) + .style( + Style::default() + .fg(Color::Black) + .bg(theme::SUCCESS) + .add_modifier(Modifier::BOLD), + ), + area, + ); +} + +fn render_lift_placeholder(frame: &mut Frame, area: Rect) { + let block = Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::BG)); + frame.render_widget(block, area); + if area.width > 4 && area.height > 2 { + frame.render_widget( + Paragraph::new("LIFTED") + .alignment(Alignment::Center) + .style(Style::default().fg(theme::TEXT_MUTED)), + Rect::new( + area.x + 1, + area.y + area.height / 2, + area.width.saturating_sub(2), + 1, + ), + ); + } +} + +pub fn render_drag_ghost(frame: &mut Frame, area: Rect, app: &App) { + let Some(drag) = app.drag.as_ref().filter(|drag| drag.moved) else { + return; + }; + let Some(issue) = app + .columns + .get(drag.source_column) + .and_then(|issues| issues.get(drag.source_index)) + else { + return; + }; + + let width = 34.min(area.width.saturating_sub(2)); + let height = 5.min(area.height.saturating_sub(2)); + if width < 4 || height < 3 { + return; + } + let max_x = area.x + area.width.saturating_sub(width + 1); + let max_y = area.y + area.height.saturating_sub(height + 1); + let x = drag.current_x.saturating_sub(6).clamp(area.x + 1, max_x); + let y = drag.current_y.saturating_sub(2).clamp(area.y + 1, max_y); + let rect = Rect::new(x, y, width, height); + let accent = state_color(issue.state); + let block = Block::new() + .title(format!(" DRAGGING #{} ", issue.iid)) + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(theme::SUCCESS)) + .style(Style::default().bg(theme::SURFACE)); + let inner = block.inner(rect); + frame.render_widget(Clear, rect); + frame.render_widget(block, rect); + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::from(vec![Span::styled( + issue.title.to_uppercase(), + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )]), + Line::from(vec![ + Span::styled("FROM ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(issue.state.title(), Style::default().fg(accent)), + Span::raw(" "), + Span::styled("DROP TO MOVE", Style::default().fg(theme::SUCCESS)), + ]), + ])), + inner, + ); +} diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs new file mode 100644 index 0000000..eda7a2c --- /dev/null +++ b/src/ui/chrome.rs @@ -0,0 +1,308 @@ +//! Topbar and status bar chrome. +//! +//! The topbar carries the brand, connection state, the search field, and the +//! primary action buttons. The status bar surfaces the latest status message +//! (or, when idle, the keyboard shortcut legend) alongside a shortcut +//! summary sized to the available width. + +use ratatui::{ + prelude::*, + widgets::{Block, BorderType, Borders, Gauge, Paragraph}, +}; + +use crate::theme; +use crate::{App, ClickTarget, StatusKind}; + +fn spans_width(spans: &[Span]) -> u16 { + spans + .iter() + .map(|span| span.content.chars().count()) + .sum::() + .try_into() + .unwrap_or(u16::MAX) +} + +/// Connection state shown after the brand chip: a success dot with the +/// project/user summary when connected, a warning when config is missing, +/// or a muted offline indicator otherwise. +fn connection_summary_spans(app: &App) -> Vec> { + match (&app.connected_project, &app.connected_user) { + (Some(project), Some(user)) => vec![ + Span::styled("● ", Style::default().fg(theme::SUCCESS)), + Span::styled( + format!("{} @{}", project.path_with_namespace, user.username), + Style::default().fg(theme::TEXT_SECONDARY), + ), + ], + (Some(project), None) => vec![ + Span::styled("● ", Style::default().fg(theme::SUCCESS)), + Span::styled( + project.path_with_namespace.clone(), + Style::default().fg(theme::TEXT_SECONDARY), + ), + ], + (None, Some(user)) => vec![ + Span::styled("● ", Style::default().fg(theme::SUCCESS)), + Span::styled( + format!("@{}", user.username), + Style::default().fg(theme::TEXT_SECONDARY), + ), + ], + (None, None) => { + if app + .story_status + .as_deref() + .is_some_and(|status| status.starts_with("Config error: missing ")) + { + vec![Span::styled( + "Config missing — Ctrl+, Settings", + Style::default().fg(theme::WARNING), + )] + } else { + vec![Span::styled( + "○ offline", + Style::default().fg(theme::TEXT_MUTED), + )] + } + } + } +} + +pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { + let block = Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::BG)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let buttons = [ + ("Connect", 11u16, false, ClickTarget::Connect), + ("Sync", 8u16, false, ClickTarget::SyncAll), + ("New Issue", 12u16, false, ClickTarget::NewIssue), + ( + "Generate Stories", + 18u16, + true, + ClickTarget::GenerateStories, + ), + ]; + let buttons_total_width = buttons.iter().map(|(_, width, _, _)| *width).sum::() + + (buttons.len().saturating_sub(1) as u16 * 2); + let buttons_fit = inner.width > buttons_total_width; + // Left content (brand, connection summary, search) must stop short of + // the button rail so narrow terminals don't overlap the two. + let left_limit = if buttons_fit { + (inner.x + inner.width).saturating_sub(buttons_total_width + 2) + } else { + inner.x + inner.width + }; + + let mut x = inner.x; + + let brand = " Lane "; + let brand_width = (brand.chars().count() as u16).min(left_limit.saturating_sub(x)); + if brand_width > 0 { + frame.render_widget( + Paragraph::new(brand).style(theme::button_primary_style()), + Rect::new(x, inner.y, brand_width, 1), + ); + x = x.saturating_add(brand_width + 1); + } + + let summary_spans = connection_summary_spans(app); + let summary_width = spans_width(&summary_spans).min(left_limit.saturating_sub(x)); + if summary_width > 0 { + frame.render_widget( + Paragraph::new(Line::from(summary_spans)), + Rect::new(x, inner.y, summary_width, 1), + ); + x = x.saturating_add(summary_width + 2); + } + + if app.search_active || !app.search_query.is_empty() { + let budget = left_limit.saturating_sub(x).min(30); + if budget > 4 { + let cursor = if app.search_active { "▌" } else { "" }; + let mut spans = vec![ + Span::styled("/", Style::default().fg(theme::TEXT_MUTED)), + Span::styled( + format!(" {}{cursor}", app.search_query), + theme::editor_value_style(true), + ), + ]; + if !app.search_query.is_empty() { + spans.push(Span::styled( + format!(" {} matches", app.search_match_count()), + Style::default().fg(theme::TEXT_MUTED), + )); + } + let width = spans_width(&spans).min(budget); + if width > 0 { + frame.render_widget( + Paragraph::new(Line::from(spans)), + Rect::new(x, inner.y, width, 1), + ); + } + } + } + + if buttons_fit { + let button_y = inner.y; + let mut bx = inner.x + inner.width.saturating_sub(buttons_total_width); + for (label, width, primary, target) in buttons { + let rect = Rect::new(bx, button_y, width, 1); + let style = if primary { + theme::button_primary_style() + } else { + theme::button_secondary_style() + }; + super::render_button(frame, rect, label, style); + app.add_hitbox(rect, target); + bx = bx.saturating_add(width + 2); + } + } +} + +pub fn render_status(frame: &mut Frame, area: Rect, app: &App) { + let block = Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::BG)); + let inner = block.inner(area); + frame.render_widget(block, area); + + if let Some(status) = app.story_status.as_deref() { + let shortcuts = status_shortcuts(app, inner.width); + let shortcut_width = inner.width.min(super::shortcut_line_width(&shortcuts)); + let shortcut_gap = u16::from(shortcut_width > 0); + let left_width = inner.width.saturating_sub(shortcut_width + shortcut_gap); + + let mut spans = Vec::new(); + if let Some(label) = app.busy.as_deref() { + spans.push(Span::styled( + format!("{} ", theme::spinner_frame(app.loading_tick)), + Style::default().fg(theme::ACCENT), + )); + spans.push(Span::styled( + format!("{label} "), + Style::default().fg(theme::ACCENT), + )); + } + let icon = match app.status_kind { + StatusKind::Success => "✓ ", + StatusKind::Error => "✗ ", + StatusKind::Warning => "⚠ ", + StatusKind::Info => "", + }; + spans.push(Span::styled( + format!("{icon}{status}"), + Style::default().fg(theme::status_color(app.status_kind)), + )); + let line = Line::from(spans); + let line_width = spans_width(line.spans.as_slice()); + + if app.is_connecting() && left_width > 36 { + let status_width = line_width.min(left_width); + frame.render_widget( + Paragraph::new(line), + Rect::new(inner.x, inner.y, status_width, 1), + ); + let gauge_width = left_width.saturating_sub(status_width + 1).min(24); + if gauge_width > 0 { + render_connecting_gauge( + frame, + Rect::new(inner.x + status_width + 1, inner.y, gauge_width, 1), + app, + ); + } + } else if left_width > 0 { + frame.render_widget( + Paragraph::new(line), + Rect::new(inner.x, inner.y, left_width, 1), + ); + } + if shortcut_width > 0 { + frame.render_widget( + Paragraph::new(super::shortcut_line(&shortcuts)).alignment(Alignment::Right), + Rect::new( + inner.x + inner.width.saturating_sub(shortcut_width), + inner.y, + shortcut_width, + 1, + ), + ); + } + return; + } + + frame.render_widget( + Paragraph::new(super::shortcut_line(&footer_shortcuts(app))).alignment(Alignment::Center), + inner, + ); +} + +fn render_connecting_gauge(frame: &mut Frame, area: Rect, app: &App) { + let ratio = f64::from((app.loading_tick % 16) + 1) / 16.0; + let gauge = Gauge::default() + .ratio(ratio) + .label("") + .style(Style::default().bg(theme::BG)) + .gauge_style( + Style::default() + .fg(Color::Black) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(gauge, area); +} + +pub(crate) fn footer_shortcuts(app: &App) -> Vec<(&'static str, &'static str)> { + if app.overlay.is_some() { + vec![("Esc", "Close"), ("q", "Quit")] + } else { + vec![ + ("c", "Connect"), + ("r", "Sync"), + ("n", "New"), + ("Enter", "Open"), + ("b", "Block"), + ("/", "Search"), + ("Arrows", "Move"), + ("?", "Help"), + ("q", "Quit"), + ] + } +} + +pub(crate) fn status_shortcuts(app: &App, width: u16) -> Vec<(&'static str, &'static str)> { + if app.overlay.is_some() { + let overlay_shortcuts = vec![("Esc", "Close"), ("q", "Quit")]; + if super::shortcut_line_width(&overlay_shortcuts) <= width { + overlay_shortcuts + } else { + vec![("q", "Quit")] + } + } else if width < 44 { + vec![("q", "Quit")] + } else if width < 64 { + vec![ + ("r", "Sync"), + ("n", "New"), + ("Enter", "Open"), + ("q", "Quit"), + ] + } else { + vec![ + ("c", "Connect"), + ("r", "Sync"), + ("n", "New"), + ("Enter", "Open"), + ("q", "Quit"), + ] + } +} diff --git a/src/ui/confirm.rs b/src/ui/confirm.rs new file mode 100644 index 0000000..89e0b92 --- /dev/null +++ b/src/ui/confirm.rs @@ -0,0 +1,60 @@ +//! Blocking confirmation dialog for destructive actions (e.g. deleting an +//! issue). Driven by `App::confirm`; the caller is responsible for clearing +//! it once the user answers. + +use ratatui::{ + prelude::*, + widgets::{Paragraph, Wrap}, +}; + +use crate::theme; +use crate::{App, ClickTarget}; + +use super::{modal_frame, render_button}; + +pub fn render_confirm_modal(frame: &mut Frame, area: Rect, app: &mut App) { + let Some(request) = app.confirm.as_ref() else { + return; + }; + let title = request.title.clone(); + let body = request.body.clone(); + + let chrome = modal_frame(frame, area, 54, 10, &title); + + // modal_frame paints the title chip in the default primary style; this + // overlay guards a destructive action, so redraw the chip in danger + // style at the same position. + let title_text = format!(" {title} "); + let title_width = (title_text.chars().count() as u16).min(chrome.rect.width); + frame.render_widget( + Paragraph::new(title_text).style(theme::button_danger_style()), + Rect::new(chrome.rect.x, chrome.rect.y, title_width, 1), + ); + + let mut lines: Vec> = body + .lines() + .map(|line| { + Line::from(Span::styled( + line.to_string(), + Style::default().fg(theme::TEXT), + )) + }) + .collect(); + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Enter confirm. Esc cancel.", + Style::default().fg(theme::TEXT_MUTED), + ))); + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + chrome.body, + ); + + let footer = chrome.footer; + let confirm = Rect::new(footer.x, footer.y, 10, 1); + let cancel = Rect::new(footer.x + 12, footer.y, 8, 1); + render_button(frame, confirm, "Confirm", theme::button_danger_style()); + render_button(frame, cancel, "Cancel", theme::button_secondary_style()); + app.add_hitbox(confirm, ClickTarget::Save); + app.add_hitbox(cancel, ClickTarget::Cancel); +} diff --git a/src/ui/help.rs b/src/ui/help.rs new file mode 100644 index 0000000..4fdd196 --- /dev/null +++ b/src/ui/help.rs @@ -0,0 +1,134 @@ +//! Help modal: a scrollable keyboard-shortcut reference. + +use ratatui::{ + prelude::*, + widgets::{Paragraph, Wrap}, +}; + +use crate::theme; +use crate::{App, ClickTarget}; + +use super::{modal_frame, render_button, ModalChrome}; + +pub fn render_help_modal(frame: &mut Frame, area: Rect, app: &mut App) { + let ModalChrome { rect, body, footer } = + modal_frame(frame, area, 72, 26, "Help — Keyboard Shortcuts"); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(body); + let (content_area, hint_row) = (rows[0], rows[1]); + + frame.render_widget( + Paragraph::new(help_lines()) + .wrap(Wrap { trim: false }) + .scroll((app.help_scroll as u16, 0)), + content_area, + ); + + frame.render_widget( + Paragraph::new(Span::styled( + "\u{2191}/\u{2193} scroll. Esc close.", + Style::default().fg(theme::TEXT_MUTED), + )), + hint_row, + ); + + let close = Rect::new(rect.x + 2, footer.y, 9, 1); + render_button(frame, close, "Close", theme::button_secondary_style()); + app.add_hitbox(close, ClickTarget::Cancel); +} + +fn help_lines() -> Vec> { + let mut lines = Vec::new(); + + push_section( + &mut lines, + "Global", + &[ + ("/", "Search issues"), + ("?", "This help"), + ("Ctrl+K", "Action palette"), + ("Ctrl+,", "Settings"), + ("c", "Connect"), + ("s / r", "Sync"), + ("q", "Quit"), + ], + ); + push_section( + &mut lines, + "Board", + &[ + ("\u{2191}\u{2193} / j k", "Move between issues"), + ("\u{2190}\u{2192} / h l", "Move between columns"), + ("Enter / e", "Open issue"), + ("n", "New issue"), + ("g", "Generate stories"), + ("b", "Toggle blocked"), + ("Esc", "Clear search"), + ], + ); + push_section( + &mut lines, + "Modals", + &[ + ("Tab", "Next field"), + ("Enter", "Confirm / newline"), + ("Ctrl+S", "Save"), + ("Esc", "Close / cancel"), + ], + ); + push_section( + &mut lines, + "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+N", "Notes (from details)"), + ], + ); + push_section( + &mut lines, + "Generate Stories", + &[ + ("Ctrl+G", "Generate"), + ("Ctrl+P", "Preview"), + ("Space", "Toggle story"), + ("Ctrl+S", "Create selected"), + ], + ); + + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Tip: Press ? anytime to open this help.", + Style::default().fg(theme::TEXT_MUTED), + ))); + + lines +} + +fn push_section(lines: &mut Vec>, title: &str, rows: &[(&str, &str)]) { + if !lines.is_empty() { + lines.push(Line::raw("")); + } + lines.push(Line::from(Span::styled( + title.to_string(), + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ))); + for (key, description) in rows { + lines.push(Line::from(vec![ + Span::styled(format!("{key:>9}"), theme::keycap_style()), + Span::raw(" "), + Span::styled( + (*description).to_string(), + Style::default().fg(theme::TEXT_SECONDARY), + ), + ])); + } +} diff --git a/src/ui/markdown.rs b/src/ui/markdown.rs new file mode 100644 index 0000000..57141ad --- /dev/null +++ b/src/ui/markdown.rs @@ -0,0 +1,410 @@ +//! Hand-rolled markdown rendering for card/issue descriptions. +//! +//! Supports a small, TUI-friendly subset of markdown: headings (levels 1-3), +//! task list items, bullets, blockquotes, ordered lists, fenced code blocks, +//! and inline bold/code/link/italic spans. There is no external markdown +//! dependency; everything here is a manual scanner over `&str`. + +use crate::theme; +use ratatui::prelude::*; + +/// Renders a description as either raw source (edit mode) or a styled +/// markdown preview, depending on `preview`. +pub fn description_lines(markdown: &str, active: bool, preview: bool) -> Vec> { + if preview { + return markdown_preview_lines(markdown); + } + markdown_source_lines(markdown, active) +} + +fn markdown_source_lines(markdown: &str, active: bool) -> Vec> { + let style = theme::editor_value_style(active); + let cursor = if active { "▌" } else { "" }; + if markdown.is_empty() { + return vec![Line::from(vec![Span::styled(format!(" {cursor}"), style)])]; + } + + let mut lines: Vec> = markdown + .split('\n') + .map(|line| Line::from(vec![Span::styled(format!(" {line}"), style)])) + .collect(); + if active { + if let Some(last) = lines.last_mut() { + last.spans.push(Span::styled(cursor, style)); + } + } + lines +} + +/// Renders a description as a styled markdown preview. +pub fn markdown_preview_lines(markdown: &str) -> Vec> { + if markdown.trim().is_empty() { + return vec![Line::from(vec![Span::styled( + "No description yet", + Style::default().fg(theme::TEXT_MUTED), + )])]; + } + + let mut lines = Vec::new(); + let mut in_code = false; + for raw_line in markdown.lines() { + let line = raw_line.trim_end(); + let trimmed = line.trim_start(); + + if trimmed.starts_with("```") { + in_code = !in_code; + continue; + } + + if in_code { + lines.push(Line::from(vec![Span::styled( + format!(" {line}"), + theme::code_style(), + )])); + continue; + } + + if trimmed.is_empty() { + lines.push(Line::raw("")); + continue; + } + + if let Some((level, heading)) = markdown_heading(trimmed) { + let style = if level == 1 { + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD) + }; + lines.push(Line::from(vec![Span::styled( + heading.to_uppercase(), + style, + )])); + continue; + } + + if let Some(task) = trimmed + .strip_prefix("- [x] ") + .or_else(|| trimmed.strip_prefix("- [X] ")) + { + lines.push(Line::from(vec![ + Span::styled( + "☑ ", + Style::default() + .fg(theme::SUCCESS) + .add_modifier(Modifier::BOLD), + ), + Span::styled(task.to_string(), Style::default().fg(theme::TEXT)), + ])); + continue; + } + + if let Some(task) = trimmed.strip_prefix("- [ ] ") { + lines.push(Line::from(vec![ + Span::styled("☐ ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(task.to_string(), Style::default().fg(theme::TEXT)), + ])); + continue; + } + + if let Some(item) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + { + let mut spans = vec![Span::styled("• ", Style::default().fg(theme::ACCENT))]; + spans.extend(markdown_inline_spans( + item, + Style::default().fg(theme::TEXT), + )); + lines.push(Line::from(spans)); + continue; + } + + if let Some((number, rest)) = markdown_ordered_list_item(trimmed) { + let mut spans = vec![Span::styled( + format!("{number}. "), + Style::default().fg(theme::ACCENT), + )]; + spans.extend(markdown_inline_spans( + rest, + Style::default().fg(theme::TEXT), + )); + lines.push(Line::from(spans)); + continue; + } + + if let Some(quote) = trimmed.strip_prefix("> ") { + let mut spans = vec![Span::styled("▎ ", Style::default().fg(theme::TEXT_MUTED))]; + spans.extend(markdown_inline_spans( + quote, + Style::default().fg(theme::TEXT_MUTED), + )); + lines.push(Line::from(spans)); + continue; + } + + lines.push(Line::from(markdown_inline_spans( + trimmed, + Style::default().fg(theme::TEXT), + ))); + } + + lines +} + +fn markdown_heading(line: &str) -> Option<(usize, &str)> { + let level = line.chars().take_while(|ch| *ch == '#').count(); + if (1..=3).contains(&level) && line.chars().nth(level) == Some(' ') { + Some((level, line[level + 1..].trim())) + } else { + None + } +} + +/// Matches a trimmed line starting with `^\d+\. ` (digits, dot, space). +/// Returns the digit run and the remaining text. Parsed manually since the +/// crate has no regex dependency. +fn markdown_ordered_list_item(line: &str) -> Option<(&str, &str)> { + let digit_end = line + .char_indices() + .find(|(_, ch)| !ch.is_ascii_digit()) + .map(|(index, _)| index)?; + if digit_end == 0 { + return None; + } + let rest = line[digit_end..].strip_prefix(". ")?; + Some((&line[..digit_end], rest)) +} + +fn markdown_inline_spans(input: &str, base_style: Style) -> Vec> { + let mut spans = Vec::new(); + let mut rest = input; + + while !rest.is_empty() { + if let Some(stripped) = rest.strip_prefix("**") { + if let Some(end) = stripped.find("**") { + spans.push(Span::styled( + stripped[..end].to_string(), + base_style.add_modifier(Modifier::BOLD), + )); + rest = &stripped[end + 2..]; + continue; + } + } + + if let Some(stripped) = rest.strip_prefix('`') { + if let Some(end) = stripped.find('`') { + spans.push(Span::styled( + stripped[..end].to_string(), + theme::code_style(), + )); + rest = &stripped[end + 1..]; + continue; + } + } + + if let Some(link) = markdown_link(rest) { + spans.push(Span::styled( + link.text.to_string(), + Style::default() + .fg(theme::ACCENT_SECONDARY) + .add_modifier(Modifier::UNDERLINED), + )); + rest = &rest[link.end..]; + continue; + } + + if let Some(stripped) = rest.strip_prefix('_') { + if let Some(end) = stripped.find('_') { + spans.push(Span::styled( + stripped[..end].to_string(), + Style::default() + .fg(theme::TEXT_SECONDARY) + .add_modifier(Modifier::ITALIC), + )); + rest = &stripped[end + 1..]; + continue; + } + } + + let next = rest + .char_indices() + .skip(1) + .find_map(|(index, ch)| { + (ch == '*' || ch == '`' || ch == '[' || ch == '_').then_some(index) + }) + .unwrap_or(rest.len()); + spans.push(Span::styled(rest[..next].to_string(), base_style)); + rest = &rest[next..]; + } + + spans +} + +struct MarkdownLink<'a> { + text: &'a str, + end: usize, +} + +fn markdown_link(input: &str) -> Option> { + if !input.starts_with('[') { + return None; + } + let close_text = input.find("](")?; + let after_open_url = close_text + 2; + let close_url = input[after_open_url..].find(')')? + after_open_url; + Some(MarkdownLink { + text: &input[1..close_text], + end: close_url + 1, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn span_text(line: &Line<'static>, index: usize) -> String { + line.spans[index].content.to_string() + } + + #[test] + fn heading_uppercases_and_styles_h1_distinctly_from_h2() { + let h1 = markdown_preview_lines("# Heading"); + assert_eq!(span_text(&h1[0], 0), "HEADING"); + assert_eq!(h1[0].spans[0].style.fg, Some(theme::ACCENT)); + assert!(h1[0].spans[0].style.add_modifier.contains(Modifier::BOLD)); + + let h2 = markdown_preview_lines("## Sub Heading"); + assert_eq!(span_text(&h2[0], 0), "SUB HEADING"); + assert_eq!(h2[0].spans[0].style.fg, Some(theme::TEXT)); + assert!(h2[0].spans[0].style.add_modifier.contains(Modifier::BOLD)); + + let h3 = markdown_preview_lines("### Sub Sub"); + assert_eq!(h3[0].spans[0].style.fg, Some(theme::TEXT)); + + // Level 4+ is not a heading at all. + let not_heading = markdown_preview_lines("#### Not a heading"); + assert_eq!(span_text(¬_heading[0], 0), "#### Not a heading"); + } + + #[test] + fn checked_and_unchecked_tasks_render_distinct_markers() { + let checked = markdown_preview_lines("- [x] Done"); + assert_eq!(span_text(&checked[0], 0), "☑ "); + assert_eq!(checked[0].spans[0].style.fg, Some(theme::SUCCESS)); + assert!(checked[0].spans[0] + .style + .add_modifier + .contains(Modifier::BOLD)); + assert_eq!(span_text(&checked[0], 1), "Done"); + assert_eq!(checked[0].spans[1].style.fg, Some(theme::TEXT)); + + let checked_upper = markdown_preview_lines("- [X] Done"); + assert_eq!(span_text(&checked_upper[0], 0), "☑ "); + + let unchecked = markdown_preview_lines("- [ ] Todo"); + assert_eq!(span_text(&unchecked[0], 0), "☐ "); + assert_eq!(unchecked[0].spans[0].style.fg, Some(theme::TEXT_MUTED)); + assert_eq!(span_text(&unchecked[0], 1), "Todo"); + } + + #[test] + fn bullets_render_marker_and_inline_item() { + let dash = markdown_preview_lines("- item one"); + assert_eq!(span_text(&dash[0], 0), "• "); + assert_eq!(dash[0].spans[0].style.fg, Some(theme::ACCENT)); + assert_eq!(span_text(&dash[0], 1), "item one"); + + let star = markdown_preview_lines("* item two"); + assert_eq!(span_text(&star[0], 0), "• "); + assert_eq!(span_text(&star[0], 1), "item two"); + } + + #[test] + fn blockquote_renders_muted_marker_and_muted_base_text() { + let lines = markdown_preview_lines("> a quote"); + assert_eq!(span_text(&lines[0], 0), "▎ "); + assert_eq!(lines[0].spans[0].style.fg, Some(theme::TEXT_MUTED)); + assert_eq!(span_text(&lines[0], 1), "a quote"); + assert_eq!(lines[0].spans[1].style.fg, Some(theme::TEXT_MUTED)); + } + + #[test] + fn fenced_code_block_drops_fences_and_styles_inner_lines() { + let markdown = "```\nlet x = 1;\nlet y = 2;\n```"; + let lines = markdown_preview_lines(markdown); + assert_eq!(lines.len(), 2); + assert_eq!(span_text(&lines[0], 0), " let x = 1;"); + assert_eq!(lines[0].spans[0].style, theme::code_style()); + assert_eq!(span_text(&lines[1], 0), " let y = 2;"); + } + + #[test] + fn inline_bold_code_link_and_italic_are_styled() { + let bold = markdown_inline_spans("**strong**", Style::default().fg(theme::TEXT)); + assert_eq!(bold[0].content.to_string(), "strong"); + assert!(bold[0].style.add_modifier.contains(Modifier::BOLD)); + + let code = markdown_inline_spans("`code`", Style::default().fg(theme::TEXT)); + assert_eq!(code[0].content.to_string(), "code"); + assert_eq!(code[0].style, theme::code_style()); + + let link = markdown_inline_spans( + "[label](https://example.com)", + Style::default().fg(theme::TEXT), + ); + assert_eq!(link[0].content.to_string(), "label"); + assert_eq!(link[0].style.fg, Some(theme::ACCENT_SECONDARY)); + assert!(link[0].style.add_modifier.contains(Modifier::UNDERLINED)); + + let italic = markdown_inline_spans("_soft_", Style::default().fg(theme::TEXT)); + assert_eq!(italic[0].content.to_string(), "soft"); + assert_eq!(italic[0].style.fg, Some(theme::TEXT_SECONDARY)); + assert!(italic[0].style.add_modifier.contains(Modifier::ITALIC)); + } + + #[test] + fn ordered_list_item_splits_number_and_text() { + let lines = markdown_preview_lines("1. First"); + assert_eq!(span_text(&lines[0], 0), "1. "); + assert_eq!(lines[0].spans[0].style.fg, Some(theme::ACCENT)); + assert_eq!(span_text(&lines[0], 1), "First"); + assert_eq!(lines[0].spans[1].style.fg, Some(theme::TEXT)); + } + + #[test] + fn empty_preview_shows_placeholder() { + let lines = markdown_preview_lines(" "); + assert_eq!(lines.len(), 1); + assert_eq!(span_text(&lines[0], 0), "No description yet"); + assert_eq!(lines[0].spans[0].style.fg, Some(theme::TEXT_MUTED)); + + let empty = markdown_preview_lines(""); + assert_eq!(span_text(&empty[0], 0), "No description yet"); + } + + #[test] + fn source_mode_cursor_present_only_when_active() { + let active = description_lines("hello", true, false); + let last = active.last().unwrap(); + assert_eq!(last.spans.last().unwrap().content.to_string(), "▌"); + + let inactive = description_lines("hello", false, false); + let last = inactive.last().unwrap(); + assert_eq!(last.spans.last().unwrap().content.to_string(), " hello"); + } + + #[test] + fn source_mode_empty_input_active_is_single_cursor_line() { + let active = description_lines("", true, false); + assert_eq!(active.len(), 1); + assert_eq!(active[0].spans[0].content.to_string(), " ▌"); + + let inactive = description_lines("", false, false); + assert_eq!(inactive.len(), 1); + assert_eq!(inactive[0].spans[0].content.to_string(), " "); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..8be5889 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,1247 @@ +//! Render layer: one module per surface, all styled through `crate::theme`. +//! +//! State lives in `crate::App`; input handling lives in `main.rs`. Modules +//! here only draw and register mouse hitboxes. + +use ratatui::{ + prelude::*, + widgets::{Block, BorderType, Borders, Clear, Paragraph}, +}; + +use crate::theme; +use crate::{App, Overlay}; + +pub mod board; +pub mod chrome; +pub mod confirm; +pub mod help; +pub mod markdown; +pub mod modals; +pub mod settings_ui; +pub mod stories; + +pub fn render(frame: &mut Frame, app: &mut App) { + app.clear_hitboxes(); + let root = frame.area(); + let shell = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(root); + + chrome::render_topbar(frame, shell[0], app); + board::render_board(frame, shell[1], app); + chrome::render_status(frame, shell[2], app); + + if let Some(overlay) = app.overlay.clone() { + render_locked_viewport(frame, root); + app.clear_hitboxes(); + match overlay { + Overlay::Palette => modals::render_palette(frame, root, app), + Overlay::Details => modals::render_issue_modal(frame, root, app, false), + Overlay::Notes => modals::render_notes_modal(frame, root, app), + Overlay::NewIssue => modals::render_issue_modal(frame, root, app, true), + Overlay::GenerateStories => stories::render_generate_stories_modal(frame, root, app), + Overlay::Settings => settings_ui::render_settings_modal(frame, root, app), + Overlay::Help => help::render_help_modal(frame, root, app), + Overlay::Confirm => confirm::render_confirm_modal(frame, root, app), + } + } else { + board::render_drag_ghost(frame, root, app); + } +} + +fn render_locked_viewport(frame: &mut Frame, area: Rect) { + let block = Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().fg(theme::TEXT_MUTED)); + frame.render_widget(block, area); +} + +/// Center a `width` x `height` rect inside `area`, clamped to fit. +pub(crate) fn centered(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width.saturating_sub(4)).max(20); + let height = height.min(area.height.saturating_sub(4)).max(9); + Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ) +} + +/// Regions of a rendered modal: the whole rect, the padded body, and the +/// single footer row reserved for buttons. +pub(crate) struct ModalChrome { + pub rect: Rect, + pub body: Rect, + pub footer: Rect, +} + +/// Draw the shared modal shell: drop shadow, cleared elevated surface, and a +/// title chip pinned to the top-left edge. Returns the content regions. +pub(crate) fn modal_frame( + frame: &mut Frame, + area: Rect, + width: u16, + height: u16, + title: &str, +) -> ModalChrome { + let rect = centered(area, width, height); + let shadow = Rect::new( + rect.x.saturating_add(2), + rect.y.saturating_add(1), + rect.width + .min(area.width.saturating_sub(rect.x.saturating_add(2))), + rect.height + .min(area.height.saturating_sub(rect.y.saturating_add(1))), + ); + frame.render_widget(Block::new().style(Style::default().bg(theme::BG)), shadow); + frame.render_widget(Clear, rect); + frame.render_widget( + Block::new().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); + frame.render_widget( + Paragraph::new(title_text).style(theme::button_primary_style()), + Rect::new(rect.x, rect.y, title_width, 1), + ); + + let body = Rect::new( + rect.x.saturating_add(2), + rect.y.saturating_add(2), + rect.width.saturating_sub(4), + rect.height.saturating_sub(5), + ); + let footer = Rect::new( + rect.x.saturating_add(2), + rect.y + rect.height.saturating_sub(2), + rect.width.saturating_sub(4), + 1, + ); + ModalChrome { rect, body, footer } +} + +pub(crate) fn render_button(frame: &mut Frame, area: Rect, label: &str, style: Style) { + frame.render_widget( + Paragraph::new(label) + .alignment(Alignment::Center) + .style(style), + area, + ); +} + +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) + .sum::() + .try_into() + .unwrap_or(u16::MAX) +} + +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::styled(key.to_string(), theme::keycap_style())); + spans.push(Span::raw(format!(" {label}"))); + } + Line::from(spans) +} + +#[cfg(test)] +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") + } + + fn render_to_string(width: u16, height: u16, app: &mut App) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + + terminal.draw(|frame| render(frame, app)).expect("render"); + + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() + } + + fn screen_row(screen: &str, width: usize, row: usize) -> String { + screen.chars().skip(width * row).take(width).collect() + } + + fn region_text(buffer: &ratatui::buffer::Buffer, width: usize, rect: Rect) -> String { + let mut text = String::new(); + for y in rect.y..rect.y + rect.height { + for x in rect.x..rect.x + rect.width { + text.push_str(buffer.content()[y as usize * width + x as usize].symbol()); + } + } + text + } + + fn bg_at(buffer: &ratatui::buffer::Buffer, width: usize, x: usize, y: usize) -> Color { + buffer.content()[y * width + x].bg + } + + fn demo_issue(iid: u64, title: &str, state: LaneState, blocked: bool) -> Issue { + Issue { + iid, + title: title.to_string(), + description: format!("{title} details."), + state, + labels: vec!["ui".into(), "polish".into()], + blocked, + source: None, + } + } + + fn issue_record(iid: u64, title: &str, state: BoardState) -> IssueRecord { + IssueRecord { + id: iid, + iid, + project_id: 42, + title: title.into(), + description: String::new(), + state, + labels: Vec::new(), + due_date: None, + weight: None, + web_url: format!("https://gitlab.example.com/me/lane/-/issues/{iid}"), + reference: format!("me/lane#{iid}"), + flags: Vec::new(), + origin: Origin::Side, + source: None, + note_count: None, + updated_at: None, + } + } + + #[test] + fn app_chrome_uses_theme_background() { + 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("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. + assert_eq!(bg_at(buffer, 120, 1, 4), theme::BG); + assert_eq!(bg_at(buffer, 120, 1, 34), theme::BG); + } + + #[test] + fn issue_modal_uses_elevated_surface_behind_buttons() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let buffer = terminal.backend().buffer(); + let modal = centered(Rect::new(0, 0, 120, 36), 62, 20); + let inner_x = (modal.x + 2) as usize; + let inner_y = (modal.y + 3) as usize; + let button_y = (modal.y + modal.height - 2) as usize; + + assert_eq!( + bg_at(buffer, 120, inner_x, inner_y), + theme::SURFACE_ELEVATED + ); + assert_eq!( + bg_at(buffer, 120, inner_x + 22, button_y - 1), + theme::SURFACE_ELEVATED + ); + } + + #[test] + fn topbar_shows_brand_connection_and_controls() { + let mut app = App::demo(); + let screen = render_to_string(120, 36, &mut app); + let topbar_row = screen_row(&screen, 120, 1); + + 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!(!screen.contains("Command")); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Connect)); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::SyncAll)); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::NewIssue)); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::GenerateStories)); + } + + #[test] + fn topbar_renders_generate_stories_next_to_new_issue() { + let mut app = App::demo(); + let screen = render_to_string(120, 36, &mut app); + + let new_issue = char_offset(&screen, "New Issue"); + let generate = char_offset(&screen, "Generate Stories"); + + assert!(generate > new_issue); + } + + #[test] + fn topbar_shows_search_query_and_match_count() { + let mut app = App::demo(); + app.start_search(); + for ch in "mouse".chars() { + app.input_search_char(ch); + } + + let screen = render_to_string(120, 36, &mut app); + let topbar_row = screen_row(&screen, 120, 1); + + assert!(topbar_row.contains("mouse"), "topbar: {topbar_row:?}"); + } + + #[test] + fn board_uses_issue_tracking_language() { + let mut app = App::demo(); + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("TODO 09")); + assert!(screen.contains("#101")); + assert!(!screen.contains("LANE TODO")); + assert!(!screen.contains("IID")); + } + + #[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("Enter Open")); + assert!(footer.contains("b Block")); + assert!(footer.contains("/ Search")); + assert!(footer.contains("? Help")); + assert!(footer.contains("q Quit")); + assert!(!footer.contains("Esc Close")); + assert!(!footer.contains("FOCUS")); + assert!(!footer.contains("READY")); + } + + #[test] + fn status_surface_shows_missing_config_in_topbar() { + let mut app = App::demo(); + app.story_status = Some("Config error: missing LANE_GITLAB_URL".into()); + + let screen = render_to_string(120, 36, &mut app); + let topbar = screen_row(&screen, 120, 1); + + assert!(topbar.contains("Config missing"), "topbar: {topbar:?}"); + } + + #[test] + fn status_surface_uses_gauge_widget_while_connecting() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.story_status = Some("Connecting to GitLab".into()); + app.loading_tick = 6; + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("board render"); + let buffer = terminal.backend().buffer(); + let footer = screen_row( + &buffer + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(), + 120, + 34, + ); + + assert!( + footer.contains("Connecting to GitLab"), + "footer: {footer:?}" + ); + assert!( + (2..80).any(|x| bg_at(buffer, 120, x, 34) == theme::ACCENT), + "connecting indicator should use the accent gauge" + ); + } + + #[test] + fn status_surface_shows_busy_spinner_label() { + let mut app = App::demo(); + app.busy = Some("Syncing".into()); + app.set_status(StatusKind::Info, "Sync all pending"); + + let screen = render_to_string(120, 36, &mut app); + let footer = screen_row(&screen, 120, 34); + + assert!(footer.contains("Syncing"), "footer: {footer:?}"); + assert!(footer.contains("Sync all pending"), "footer: {footer:?}"); + } + + #[test] + fn status_surface_prefixes_success_and_error_icons() { + let mut app = App::demo(); + app.set_status(StatusKind::Success, "Settings saved"); + let screen = render_to_string(120, 36, &mut app); + assert!(screen_row(&screen, 120, 34).contains("✓ Settings saved")); + + app.set_status(StatusKind::Error, "GitLab returned 401"); + let screen = render_to_string(120, 36, &mut app); + assert!(screen_row(&screen, 120, 34).contains("✗ GitLab returned 401")); + } + + #[test] + fn status_surface_shows_connection_context_and_job_status() { + let mut app = App::demo(); + app.connected_user = Some(GitLabUser { + id: 7, + username: "alice".into(), + name: "Alice Example".into(), + }); + app.connected_project = Some(GitLabProject { + id: 42, + path_with_namespace: "me/lane".into(), + web_url: "https://gitlab.example.com/me/lane".into(), + }); + app.story_status = Some("Bootstrap: 2 created, 6 already present".into()); + + let screen = render_to_string(120, 36, &mut app); + let topbar = screen_row(&screen, 120, 1); + let footer = screen_row(&screen, 120, 34); + + assert!(topbar.contains("me/lane"), "topbar: {topbar:?}"); + assert!(topbar.contains("@alice"), "topbar: {topbar:?}"); + assert!( + footer.contains("Bootstrap: 2 created, 6 already present"), + "footer: {footer:?}" + ); + } + + #[test] + fn notes_modal_shows_latest_notes_first() { + let mut app = App::demo(); + app.overlay = Some(Overlay::Notes); + app.notes_issue_iid = Some(101); + app.notes = (1..=6) + .map(|id| GitLabNote { + id, + body: format!("Note {id}"), + created_at: None, + }) + .collect(); + + let screen = render_to_string(120, 36, &mut app); + + assert!( + screen.find("Note 6").unwrap() < screen.find("Note 5").unwrap(), + "screen: {screen:?}" + ); + assert!(!screen.contains("Note 1")); + } + + #[test] + fn status_surface_preserves_critical_shortcuts_when_status_is_long() { + let mut app = App::demo(); + app.story_status = Some( + "Bootstrap failed: very long GitLab error body should not hide close and quit hints" + .into(), + ); + + let screen = render_to_string(32, 36, &mut app); + let footer = screen_row(&screen, 32, 34); + + assert!(footer.contains("Bootstrap"), "footer: {footer:?}"); + assert!(footer.contains("q Quit"), "footer: {footer:?}"); + assert!(!footer.contains("Esc Close"), "footer: {footer:?}"); + assert!( + !footer.contains("very long GitLab error body"), + "footer: {footer:?}" + ); + } + + #[test] + fn status_surface_preserves_overlay_close_shortcut_when_narrow() { + let mut app = App::demo(); + app.overlay = Some(Overlay::Details); + app.story_status = Some("Bootstrap failed: remote GitLab unavailable".into()); + + let screen = render_to_string(32, 36, &mut app); + let footer = screen_row(&screen, 32, 34); + + assert!(footer.contains("Bootstrap"), "footer: {footer:?}"); + assert!(footer.contains("Esc Close"), "footer: {footer:?}"); + assert!(footer.contains("q Quit"), "footer: {footer:?}"); + } + + #[test] + fn modal_uses_production_issue_copy() { + let mut app = App::demo(); + app.open_details(); + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("Issue #101")); + assert!(screen.contains("Title")); + assert!(screen.contains("Description")); + assert!(screen.contains("Save")); + assert!(screen.contains("Cancel")); + for stale in [ + "ORDER", + "LOCKED TICKET", + "POP OUT PANEL", + "Edit Issue", + "Update issue details", + ] { + assert!(!screen.contains(stale), "stale modal copy: {stale}"); + } + } + + #[test] + fn palette_uses_board_action_language() { + let mut app = App::demo(); + app.open_palette(); + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("Board Actions")); + assert!(!screen.contains("BROKER COMMAND")); + } + + #[test] + fn palette_uses_premium_marker_and_cursor() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_palette(); + app.palette_query.push('m'); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("palette render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(screen.contains("m▌")); + assert!(screen.contains("▸")); + } + + #[test] + fn clicking_palette_row_executes_command() { + let mut app = App::demo(); + app.open_palette(); + app.palette_query = "generate".into(); + let _screen = render_to_string(120, 36, &mut app); + let target = app + .hitboxes + .iter() + .find_map(|hitbox| match hitbox.target { + ClickTarget::PaletteCommand { index } => { + Some(ClickTarget::PaletteCommand { index }) + } + _ => None, + }) + .expect("palette command hitbox"); + + app.click(target); + + assert_eq!(app.overlay, Some(Overlay::GenerateStories)); + } + + #[test] + fn narrow_terminal_drag_render_does_not_panic() { + let mut app = App::demo(); + app.drag = Some(DragState { + source_column: 0, + source_index: 0, + current_x: 8, + current_y: 4, + over: None, + moved: true, + }); + + let _screen = render_to_string(12, 8, &mut app); + } + + #[test] + fn new_issue_modal_renders_after_empty_sync() { + let mut app = App::demo(); + app.replace_issue_records(&[issue_record(0, "placeholder", BoardState::Todo)]); + app.replace_issue_records(&[]); + app.open_new_issue(); + + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("Issue #101")); + } + + #[test] + fn render_populates_board_and_overlay_hitboxes() { + let backend = ratatui::backend::TestBackend::new(100, 32); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("board render"); + + assert!(app.hitboxes.iter().any(|hitbox| hitbox.target + == ClickTarget::Issue { + column: 0, + index: 0 + })); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::NewIssue)); + + app.open_details(); + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Save)); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Cancel)); + } + + #[test] + fn board_renders_three_solid_lanes() { + let backend = ratatui::backend::TestBackend::new(100, 32); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("board render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert_eq!(app.columns.len(), 3); + assert!(screen.contains("TODO")); + assert!(screen.contains("IN PROGRESS")); + assert!(screen.contains("DONE")); + assert!(!screen.contains("REVIEW")); + assert!(screen.contains("┌")); + assert!(screen.contains("─")); + assert!(screen.contains("│")); + assert!(!screen.contains("+")); + } + + #[test] + fn board_shows_empty_state_for_empty_columns() { + let mut app = App::demo(); + app.replace_issue_records(&[]); + + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("No issues"), "screen missing empty state"); + } + + #[test] + fn board_shows_no_matches_when_search_filters_all() { + let mut app = App::demo(); + app.start_search(); + for ch in "zzz-no-match".chars() { + app.input_search_char(ch); + } + + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("No matches"), "screen missing filter state"); + } + + #[test] + fn topbar_does_not_repeat_lane_labels() { + 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("board render"); + let topbar: String = terminal + .backend() + .buffer() + .content() + .iter() + .take(120 * 3) + .map(|cell| cell.symbol()) + .collect(); + + assert!(!topbar.contains("TODO")); + assert!(!topbar.contains("IN PROGRESS")); + assert!(!topbar.contains("DONE")); + assert!(!topbar.contains("HOT")); + assert!(!topbar.contains("SPREAD")); + assert!(!topbar.contains("BOARD VIEW")); + assert!(!topbar.contains("DRAG TO MOVE")); + } + + #[test] + fn board_avoids_ascii_art_status_and_drop_markers() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.drag = Some(DragState { + source_column: 0, + source_index: 1, + current_x: 42, + current_y: 14, + over: Some(DropTarget::Column { column: 1 }), + moved: true, + }); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("drag render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(!screen.contains(">>>")); + assert!(!screen.contains("<<<")); + assert!(!screen.contains("$")); + assert!(!screen.contains("ctrl+k")); + } + + #[test] + fn blocked_badge_is_pinned_to_card_top_right() { + let backend = ratatui::backend::TestBackend::new(48, 6); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let blocked = demo_issue(902, "Blocked placement", LaneState::Todo, true); + let appearance = crate::settings::AppearanceSettings::default(); + + terminal + .draw(|frame| { + board::render_card( + frame, + Rect::new(0, 0, 48, 4), + &blocked, + false, + theme::WARNING, + &appearance, + ) + }) + .expect("card render"); + let buffer = terminal.backend().buffer(); + let title_row: String = buffer.content()[48..96] + .iter() + .map(|cell| cell.symbol()) + .collect(); + let label_row: String = buffer.content()[96..144] + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!( + title_row.ends_with(" BLOCKED │"), + "title row: {title_row:?}" + ); + assert!(!label_row.contains("BLOCKED")); + } + + #[test] + fn modal_renders_premium_form_chrome() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(screen.contains("Issue #101")); + assert!(screen.contains("Title")); + assert!(screen.contains("Description")); + assert!(screen.contains("Tab switches field")); + } + + #[test] + fn modal_panel_is_borderless() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let buffer = terminal.backend().buffer(); + let modal = centered(Rect::new(0, 0, 120, 36), 62, 20); + let top_left = buffer.content()[(modal.y as usize * 120) + modal.x as usize].symbol(); + let top_edge = buffer.content()[(modal.y as usize * 120) + modal.x as usize + 1].symbol(); + let left_edge = + buffer.content()[((modal.y as usize + 1) * 120) + modal.x as usize].symbol(); + + assert_ne!(top_left, "+"); + assert_ne!(top_edge, "-"); + assert_ne!(left_edge, "|"); + } + + #[test] + fn renderer_uses_solid_single_line_borders_only() { + let double_border = ["BorderType", "::", "Double"].concat(); + + for source in [ + include_str!("mod.rs"), + include_str!("board.rs"), + include_str!("chrome.rs"), + include_str!("modals.rs"), + include_str!("stories.rs"), + include_str!("settings_ui.rs"), + include_str!("help.rs"), + include_str!("confirm.rs"), + include_str!("markdown.rs"), + ] { + assert!(!source.contains(&double_border)); + } + } + + #[test] + fn render_drag_state_shows_drop_target_and_floating_card() { + let backend = ratatui::backend::TestBackend::new(100, 32); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.drag = Some(DragState { + source_column: 0, + source_index: 1, + current_x: 42, + current_y: 14, + over: Some(DropTarget::Column { column: 1 }), + moved: true, + }); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("drag render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(screen.contains("DROP TARGET")); + assert!(screen.contains("DRAGGING #102")); + } + + #[test] + fn modal_buttons_are_compact_one_line_controls() { + let backend = ratatui::backend::TestBackend::new(100, 32); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + + let save = app + .hitboxes + .iter() + .find(|hitbox| hitbox.target == ClickTarget::Save) + .expect("save hitbox") + .rect; + let cancel = app + .hitboxes + .iter() + .find(|hitbox| hitbox.target == ClickTarget::Cancel) + .expect("cancel hitbox") + .rect; + + assert_eq!(save.height, 1); + assert!(save.width <= 14); + assert_eq!(cancel.height, 1); + assert!(cancel.width <= 8); + } + + #[test] + fn modal_disables_background_without_blacking_it_out() { + let backend = ratatui::backend::TestBackend::new(100, 32); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(screen.contains("Issue #101")); + assert!(app + .hitboxes + .iter() + .all(|hitbox| matches!(hitbox.target, ClickTarget::Save | ClickTarget::Cancel))); + } + + #[test] + fn modal_button_mouse_sequence_closes_without_reopening() { + let backend = ratatui::backend::TestBackend::new(100, 32); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let save = app + .hitboxes + .iter() + .find(|hitbox| hitbox.target == ClickTarget::Save) + .expect("save hitbox") + .rect; + let x = save.x + save.width / 2; + let y = save.y; + + app.mouse_down_at(x, y); + assert_eq!(app.overlay, Some(Overlay::Details)); + + app.mouse_up_at(x, y); + assert_eq!(app.overlay, None); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("board render"); + assert_eq!(app.overlay, None); + } + + #[test] + fn issue_modal_render_uses_description_scroll_offset() { + let mut app = App::demo(); + app.open_details(); + app.focus_next_editor_field(); + app.editor_description = (0..20) + .map(|index| format!("line {index:02}")) + .collect::>() + .join("\n"); + app.scroll_editor_description(8); + + let screen = render_to_string(120, 36, &mut app); + + assert!( + screen.contains("line 08"), + "screen should show scrolled line" + ); + assert!( + !screen.contains("line 00"), + "screen should hide initial line" + ); + } + + #[test] + fn ai_editor_modal_renders_labels_and_shortcuts() { + let mut app = App::demo(); + app.open_details(); + app.editor_labels = vec!["backend".into()]; + + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("Labels")); + assert!(screen.contains("backend")); + assert!(screen.contains("Ctrl+A Improve")); + assert!(screen.contains("Ctrl+Y Criteria")); + assert!(screen.contains("Ctrl+L Labels")); + } + + #[test] + fn ai_editor_escape_copy_changes_while_ai_in_flight() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + app.request_improve_description(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let modal = centered(Rect::new(0, 0, 120, 36), 62, 20); + let modal_text = region_text(terminal.backend().buffer(), 120, modal); + + assert!(modal_text.contains("Esc")); + assert!(modal_text.contains("Cancel")); + assert!(modal_text.contains("AI")); + } + + #[test] + fn modal_renders_editable_description_buffer() { + let backend = ratatui::backend::TestBackend::new(100, 32); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + + app.open_details(); + app.focus_next_editor_field(); + app.clear_editor(); + for ch in "Editable description".chars() { + app.input_editor_char(ch); + } + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(screen.contains("Description")); + assert!(screen.contains("Editable description▌")); + } + + #[test] + fn markdown_preview_renders_rich_description() { + let backend = ratatui::backend::TestBackend::new(120, 36); + let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); + let mut app = App::demo(); + app.open_details(); + app.focus_next_editor_field(); + app.clear_editor(); + for ch in "# Heading\n- [x] Done\n`code` and **bold**".chars() { + if ch == '\n' { + app.input_editor_newline(); + } else { + app.input_editor_char(ch); + } + } + app.toggle_markdown_preview(); + + terminal + .draw(|frame| render(frame, &mut app)) + .expect("modal render"); + let screen: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + + assert!(app.editor_preview); + assert!(screen.contains("Preview")); + assert!(screen.contains("HEADING")); + assert!(screen.contains("☑ Done")); + assert!(screen.contains("code")); + assert!(screen.contains("bold")); + assert!(!screen.contains("# Heading")); + assert!(!screen.contains("**bold**")); + } + + #[test] + fn generate_stories_modal_shows_tabs() { + let mut app = App::demo(); + app.open_generate_stories(); + + let screen = render_to_string(120, 40, &mut app); + + assert!(screen.contains("Generate Stories")); + assert!(screen.contains("Edit")); + assert!(screen.contains("Preview")); + assert!(screen.contains("Generated (0)")); + assert!(screen.contains("Create Selected")); + } + + #[test] + fn generate_stories_modal_shows_generation_spinner() { + let mut app = App::demo(); + app.open_generate_stories(); + app.request_story_generation(Vec::new()); + + let screen = render_to_string(120, 40, &mut app); + + assert!( + screen.contains("Generating stories"), + "missing generation feedback" + ); + } + + #[test] + fn settings_modal_renders_tabs_and_masks_secrets() { + let mut app = App::demo(); + app.settings.gitlab.token = "glpat-super-secret".into(); + app.open_settings(); + + let screen = render_to_string(120, 40, &mut app); + + assert!(screen.contains("Settings")); + assert!(screen.contains("GitLab")); + assert!(screen.contains("AI")); + assert!(screen.contains("Appearance")); + assert!(screen.contains("Advanced")); + assert!(screen.contains("Access Token")); + assert!( + !screen.contains("glpat-super-secret"), + "secret must be masked" + ); + assert!(screen.contains("•"), "masked secret should render dots"); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::SettingsSection { index: 1 })); + } + + #[test] + fn settings_modal_shows_inline_validation_errors() { + let mut app = App::demo(); + app.open_settings(); + { + let form = app.settings_form.as_mut().expect("form"); + form.draft.gitlab.base_url = "not a url".into(); + } + app.save_settings(); + + let screen = render_to_string(120, 40, &mut app); + + assert!( + screen.contains("must be an http(s) URL"), + "missing inline error" + ); + } + + #[test] + fn help_modal_lists_shortcut_sections() { + let mut app = App::demo(); + app.open_help(); + + let screen = render_to_string(120, 40, &mut app); + + assert!(screen.contains("Keyboard Shortcuts")); + assert!(screen.contains("Search issues")); + assert!(screen.contains("Generate")); + assert!(screen.contains("Settings")); + } + + #[test] + fn confirm_modal_renders_title_body_and_buttons() { + let mut app = App::demo(); + app.request_delete_issue(); + + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("Delete issue #101?")); + assert!(screen.contains("cannot be undone")); + assert!(screen.contains("Confirm")); + assert!(screen.contains("Cancel")); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Save)); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Cancel)); + } + + #[test] + fn compact_density_reduces_card_height() { + let mut app = App::demo(); + app.settings.appearance.compact = true; + let compact_screen = render_to_string(120, 36, &mut app); + + assert!(compact_screen.contains("#101")); + assert!(compact_screen.contains("#102")); + // Compact cards fit more issues in the same viewport than cozy cards. + let mut cozy = App::demo(); + let cozy_screen = render_to_string(120, 36, &mut cozy); + let compact_cards = (101..=109) + .filter(|iid| compact_screen.contains(&format!("#{iid}"))) + .count(); + let cozy_cards = (101..=109) + .filter(|iid| cozy_screen.contains(&format!("#{iid}"))) + .count(); + assert!(compact_cards >= cozy_cards); + } + + #[test] + fn hidden_issue_ids_respect_appearance_setting() { + let mut app = App::demo(); + app.settings.appearance.show_ids = false; + + let screen = render_to_string(120, 36, &mut app); + + assert!(!screen.contains("#101")); + assert!(screen.contains("Draft user story")); + } +} diff --git a/src/ui/modals.rs b/src/ui/modals.rs new file mode 100644 index 0000000..b8b26a0 --- /dev/null +++ b/src/ui/modals.rs @@ -0,0 +1,250 @@ +//! Issue editor, notes drawer, and command palette overlays. + +use ratatui::{ + prelude::*, + 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}; + +pub fn render_issue_modal(frame: &mut Frame, area: Rect, app: &mut App, is_new: bool) { + let issue_id = if is_new { + app.next_iid() + } else { + let Some(issue) = app.selected_issue_opt() else { + return; + }; + issue.iid + }; + + let chrome = modal_frame(frame, area, 62, 20, &format!("Issue #{issue_id}")); + + let title_text = app.editor_title.as_str(); + let description_text = app.editor_description.as_str(); + let title_active = app.editor_field == EditorField::Title; + let title_cursor = if title_active { "▌" } else { "" }; + let description_active = app.editor_field == EditorField::Description; + let description_label = if app.editor_preview { + "Description Preview" + } else if description_active { + "Description Edit" + } else { + "Description" + }; + let hint = if app.editor_preview { + "Ctrl+P Edit. PgUp/PgDn Scroll. Ctrl+S Save." + } else if description_active { + "Enter newline. Up/Down or PgUp/PgDn scroll. Ctrl+P Preview. Ctrl+S Save." + } else { + "Tab switches field. Enter saves." + }; + let labels = if app.editor_labels.is_empty() { + "(none)".to_string() + } else { + app.editor_labels.join(", ") + }; + let ai_hint_text = if app.ai_job_in_flight { + "Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel AI." + } else { + "Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel." + }; + let ai_hint = if app.ai_job_in_flight { + format!( + "{} AI working… {ai_hint_text}", + theme::spinner_frame(app.loading_tick) + ) + } else { + ai_hint_text.to_string() + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Issue ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled( + title_text.to_string(), + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::raw(""), + Line::from(vec![Span::styled( + if title_active { + "Title Active" + } else { + "Title" + }, + Style::default().fg(theme::TEXT_MUTED), + )]), + Line::from(vec![Span::styled( + format!(" {title_text}{title_cursor}"), + theme::editor_value_style(title_active), + )]), + Line::raw(""), + Line::from(vec![Span::styled( + description_label, + Style::default().fg(theme::TEXT_MUTED), + )]), + ]; + lines.extend(description_lines( + description_text, + description_active, + app.editor_preview, + )); + lines.push(Line::raw("")); + lines.push(Line::from(vec![Span::styled( + "Labels", + Style::default().fg(theme::TEXT_MUTED), + )])); + lines.push(Line::from(vec![Span::styled( + format!(" {labels}"), + theme::editor_value_style(false), + )])); + lines.push(Line::raw("")); + lines.push(Line::from(vec![Span::styled( + ai_hint, + Style::default().fg(theme::TEXT_MUTED), + )])); + lines.push(Line::from(vec![Span::styled( + hint, + Style::default().fg(theme::TEXT_MUTED), + )])); + + frame.render_widget( + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll(( + app.editor_description_scroll.min(u16::MAX as usize) as u16, + 0, + )), + chrome.body, + ); + + render_modal_footer(frame, app, chrome.rect, chrome.footer); +} + +pub fn render_notes_modal(frame: &mut Frame, area: Rect, app: &mut App) { + let iid = app + .notes_issue_iid + .unwrap_or_else(|| app.selected_issue().iid); + + let chrome = modal_frame(frame, area, 62, 18, &format!("Notes #{iid}")); + + let mut lines = vec![Line::from(vec![Span::styled( + "Latest Notes", + Style::default().fg(theme::TEXT_MUTED), + )])]; + if app.notes.is_empty() { + lines.push(Line::from(vec![Span::styled( + " No notes yet", + Style::default().fg(theme::TEXT_MUTED), + )])); + } else { + for note in app.notes.iter().rev().take(5) { + lines.push(Line::from(vec![Span::styled( + format!("- {}", note.body.lines().next().unwrap_or_default()), + Style::default().fg(theme::TEXT), + )])); + } + } + lines.push(Line::raw("")); + lines.push(Line::from(vec![Span::styled( + "Add Note", + Style::default().fg(theme::TEXT_MUTED), + )])); + if app.note_input.is_empty() { + lines.push(Line::from(vec![Span::styled( + " Type note here", + theme::editor_value_style(true), + )])); + } else { + for line in app.note_input.lines() { + lines.push(Line::from(vec![Span::styled( + format!(" {line}"), + theme::editor_value_style(true), + )])); + } + } + lines.push(Line::raw("")); + lines.push(Line::from(vec![Span::styled( + "Ctrl+S save. Enter newline. Esc close.", + Style::default().fg(theme::TEXT_MUTED), + )])); + lines.push(Line::from(vec![Span::styled( + "Ctrl+P pull source snapshot.", + Style::default().fg(theme::TEXT_MUTED), + )])); + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + chrome.body, + ); + + render_modal_footer(frame, app, chrome.rect, chrome.footer); +} + +/// Shared Save/Cancel footer used by the issue and notes modals: an elevated +/// strip behind two buttons, with matching click hitboxes. +fn render_modal_footer(frame: &mut Frame, app: &mut App, rect: Rect, footer: Rect) { + frame.render_widget( + Paragraph::new(" ").style(Style::default().bg(theme::SURFACE_ELEVATED)), + Rect::new(rect.x, footer.y.saturating_sub(1), rect.width, 2), + ); + let save = Rect::new(footer.x, footer.y, 10, 1); + let cancel = Rect::new(footer.x + 12, footer.y, 8, 1); + render_button(frame, save, "Save", theme::button_primary_style()); + render_button(frame, cancel, "Cancel", theme::button_secondary_style()); + app.add_hitbox(save, ClickTarget::Save); + app.add_hitbox(cancel, ClickTarget::Cancel); +} + +pub fn render_palette(frame: &mut Frame, area: Rect, app: &mut App) { + let chrome = modal_frame(frame, area, 56, 20, "Board Actions"); + let body = chrome.body; + + let input = Paragraph::new(Line::from(vec![ + Span::styled("Search ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled( + format!("{}▌", app.palette_query), + Style::default().fg(theme::TEXT), + ), + ])) + .block( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(theme::BORDER)), + ); + frame.render_widget(input, Rect::new(body.x, body.y, body.width, 3)); + + let commands = app.commands(); + for (index, command) in commands.iter().enumerate() { + let y = body.y + 4 + index as u16; + if y >= body.y + body.height { + break; + } + let selected = index == app.palette_selected; + let row_style = if selected { + Style::default() + .fg(Color::Black) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme::TEXT_SECONDARY) + .bg(theme::SURFACE_ELEVATED) + }; + let marker = if selected { "▸" } else { " " }; + let line = Line::from(vec![ + Span::styled(format!(" {marker} {}", command.name), row_style), + Span::styled(format!(" {:>8} ", command.shortcut), row_style), + ]); + let row = Rect::new(body.x, y, body.width, 1); + frame.render_widget(Paragraph::new(line).style(row_style), row); + app.add_hitbox(row, ClickTarget::PaletteCommand { index }); + } +} diff --git a/src/ui/settings_ui.rs b/src/ui/settings_ui.rs new file mode 100644 index 0000000..dfe6092 --- /dev/null +++ b/src/ui/settings_ui.rs @@ -0,0 +1,184 @@ +//! Settings modal: tabbed sections over the persisted `Settings` draft. + +use ratatui::{prelude::*, widgets::Paragraph}; + +use crate::settings::SettingsForm; +use crate::theme::{self, editor_value_style}; +use crate::{App, ClickTarget}; + +use super::{modal_frame, render_button, ModalChrome}; + +pub fn render_settings_modal(frame: &mut Frame, area: Rect, app: &mut App) { + let Some(form) = app.settings_form.clone() else { + return; + }; + + let ModalChrome { rect, body, footer } = modal_frame(frame, area, 70, 24, "Settings"); + + let mut constraints = vec![Constraint::Length(1), Constraint::Length(1)]; + let mut field_has_error = Vec::with_capacity(form.field_count()); + for field in 0..form.field_count() { + let has_error = form.error_for(form.section, field).is_some(); + constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(1)); + if has_error { + constraints.push(Constraint::Length(1)); + } + field_has_error.push(has_error); + } + constraints.push(Constraint::Min(0)); + constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(1)); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(body); + + let tab_row = rows[0]; + render_tab_row(frame, tab_row, &form, app); + + let mut cursor = 2; + for (field, has_error) in field_has_error.iter().enumerate() { + let label_row = rows[cursor]; + let value_row = rows[cursor + 1]; + cursor += 2; + render_field(frame, label_row, value_row, &form, field); + if *has_error { + let error_row = rows[cursor]; + cursor += 1; + if let Some(message) = form.error_for(form.section, field) { + frame.render_widget( + Paragraph::new(Span::styled( + format!(" ✗ {message}"), + Style::default().fg(theme::ERROR), + )), + error_row, + ); + } + } + } + + let feedback_row = rows[rows.len() - 2]; + if let Some(feedback) = &form.feedback { + let line = match feedback { + Ok(message) => Line::from(Span::styled( + format!("✓ {message}"), + Style::default().fg(theme::SUCCESS), + )), + Err(message) => Line::from(Span::styled( + format!("✗ {message}"), + Style::default().fg(theme::ERROR), + )), + }; + frame.render_widget(Paragraph::new(line), feedback_row); + } + + 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.", + Style::default().fg(theme::TEXT_MUTED), + )), + hint_row, + ); + + let save = Rect::new(rect.x + 2, footer.y, 13, 1); + let cancel = Rect::new(rect.x + 17, footer.y, 13, 1); + render_button(frame, save, "Save (Ctrl+S)", theme::button_primary_style()); + render_button( + frame, + cancel, + "Cancel (Esc)", + theme::button_secondary_style(), + ); + app.add_hitbox(save, ClickTarget::Save); + app.add_hitbox(cancel, ClickTarget::Cancel); +} + +fn render_tab_row(frame: &mut Frame, area: Rect, form: &SettingsForm, app: &mut App) { + let mut spans = Vec::new(); + let mut x = area.x; + for (index, title) in crate::settings::SECTION_TITLES.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + x += 2; + } + let active = index == form.section; + let style = if active { + Style::default() + .fg(Color::Black) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT_SECONDARY) + }; + let width = title.chars().count() as u16; + app.add_hitbox( + Rect::new(x, area.y, width + 2, 1), + ClickTarget::SettingsSection { index }, + ); + spans.push(Span::styled(*title, style)); + x += width; + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_field( + frame: &mut Frame, + label_row: Rect, + value_row: Rect, + form: &SettingsForm, + index: usize, +) { + let focused = form.focused == index; + let label_text = if focused { + format!("{} Active", form.field_label(index)) + } else { + form.field_label(index).to_string() + }; + frame.render_widget( + Paragraph::new(Span::styled( + label_text, + Style::default().fg(theme::TEXT_MUTED), + )), + label_row, + ); + + let value = form.field_value(index); + let line = if form.field_is_toggle(index) { + let on = value == "on"; + let text = if on { " [x] on" } else { " [ ] off" }; + Line::from(Span::styled( + text, + Style::default().fg(if on { + theme::SUCCESS + } else { + theme::TEXT_MUTED + }), + )) + } else if form.field_is_secret(index) { + if value.is_empty() { + Line::from(Span::styled( + " (not set)", + Style::default().fg(theme::TEXT_MUTED), + )) + } else { + let masked = "\u{2022}".repeat(value.chars().count().min(24)); + let style = editor_value_style(focused); + let mut spans = vec![Span::styled(format!(" {masked}"), style)]; + if focused { + spans.push(Span::styled("\u{258c}", style)); + } + Line::from(spans) + } + } else { + let style = editor_value_style(focused); + let mut spans = vec![Span::styled(format!(" {value}"), style)]; + if focused { + spans.push(Span::styled("\u{258c}", style)); + } + Line::from(spans) + }; + frame.render_widget(Paragraph::new(line), value_row); +} diff --git a/src/ui/stories.rs b/src/ui/stories.rs new file mode 100644 index 0000000..13153d3 --- /dev/null +++ b/src/ui/stories.rs @@ -0,0 +1,302 @@ +//! Generate Stories modal: paste a source doc, preview it, generate story +//! drafts, and pick which ones to create. + +use ratatui::{ + prelude::*, + widgets::{Block, Paragraph, Wrap}, +}; + +use crate::theme::{self, editor_value_style}; +use crate::{App, ClickTarget, StoryModalFocus, StoryTab}; + +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 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 + ]) + .split(body); + let (tab_row, content_area, status_row, hint_row) = (rows[0], rows[2], rows[3], rows[4]); + + frame.render_widget(Paragraph::new(tab_bar_line(app, tab_row.width)), tab_row); + + match app.story_tab() { + StoryTab::Edit => render_edit_tab(frame, content_area, app), + StoryTab::Preview => render_preview_tab(frame, content_area, app), + StoryTab::Generated => render_generated_tab(frame, content_area, app), + } + + render_status_row(frame, status_row, app); + + frame.render_widget( + Paragraph::new(Span::styled( + hint_for(app), + Style::default().fg(theme::TEXT_MUTED), + )), + hint_row, + ); + + let strip = Rect::new(rect.x, footer.y.saturating_sub(1), rect.width, 2); + frame.render_widget( + Block::new().style(Style::default().bg(theme::SURFACE_ELEVATED)), + strip, + ); + let create = Rect::new(rect.x + 2, footer.y, 17, 1); + let cancel = Rect::new(rect.x + 21, footer.y, 8, 1); + render_button( + frame, + create, + "Create Selected", + theme::button_primary_style(), + ); + render_button(frame, cancel, "Cancel", theme::button_secondary_style()); + app.add_hitbox(create, ClickTarget::CreateSelectedStories); + app.add_hitbox(cancel, ClickTarget::Cancel); +} + +fn tab_bar_line(app: &App, width: u16) -> Line<'static> { + let active = app.story_tab(); + let labels = [ + (StoryTab::Edit, "Edit".to_string()), + (StoryTab::Preview, "Preview".to_string()), + ( + StoryTab::Generated, + format!("Generated ({})", app.generated_stories.len()), + ), + ]; + + let mut spans = Vec::new(); + let mut used: u16 = 0; + for (index, (tab, label)) in labels.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + used += 2; + } + let style = if *tab == active { + Style::default() + .fg(Color::Black) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT_SECONDARY) + }; + used += label.chars().count() as u16; + spans.push(Span::styled(label.clone(), style)); + } + + let hint = "Ctrl+P preview"; + let hint_len = hint.chars().count() as u16; + if width > used + hint_len + 2 { + let gap = width - used - hint_len; + spans.push(Span::raw(" ".repeat(gap as usize))); + spans.push(Span::styled(hint, Style::default().fg(theme::TEXT_MUTED))); + } + + Line::from(spans) +} + +fn render_edit_tab(frame: &mut Frame, area: Rect, app: &App) { + if area.height == 0 { + return; + } + let label_area = Rect::new(area.x, area.y, area.width, 1); + let text_area = Rect::new( + area.x, + area.y + 1, + area.width, + area.height.saturating_sub(1), + ); + + let label = if app.story_focus == StoryModalFocus::Source && !app.story_preview { + "Description or ADR Active" + } else { + "Description or ADR" + }; + frame.render_widget( + Paragraph::new(Span::styled(label, Style::default().fg(theme::TEXT_MUTED))), + label_area, + ); + + let source_focused = app.story_focus == StoryModalFocus::Source; + let lines: Vec> = if app.story_source.is_empty() { + vec![Line::from(Span::styled( + " Paste Markdown source here", + editor_value_style(true), + ))] + } else { + let style = editor_value_style(source_focused); + let mut lines: Vec> = app + .story_source + .lines() + .map(|line| Line::from(Span::styled(format!(" {line}"), style))) + .collect(); + if source_focused { + if let Some(last) = lines.last_mut() { + last.spans.push(Span::styled("▌", style)); + } + } + lines + }; + + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((app.story_source_scroll as u16, 0)), + text_area, + ); +} + +fn render_preview_tab(frame: &mut Frame, area: Rect, app: &App) { + let lines = super::markdown::markdown_preview_lines(&app.story_source); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((app.story_source_scroll as u16, 0)), + area, + ); +} + +fn render_generated_tab(frame: &mut Frame, area: Rect, app: &App) { + if app.generated_stories.is_empty() { + frame.render_widget( + Paragraph::new(Span::styled( + "No stories generated yet.", + Style::default().fg(theme::TEXT_MUTED), + )), + area, + ); + return; + } + + const WINDOW: usize = 8; + let total = app.generated_stories.len(); + let selected = app.story_selected.min(total.saturating_sub(1)); + let start = if total <= WINDOW { + 0 + } else { + selected.saturating_sub(WINDOW / 2).min(total - WINDOW) + }; + let end = (start + WINDOW).min(total); + + let mut lines: Vec> = Vec::new(); + for index in start..end { + let draft = &app.generated_stories[index]; + let is_selected_row = index == selected; + + let mut title = draft.story.title.clone(); + if app.story_focus == StoryModalFocus::Edit && is_selected_row { + title.push('▌'); + } + let title_bg = if is_selected_row { + theme::SURFACE + } else { + theme::SURFACE_ELEVATED + }; + lines.push(Line::from(vec![ + Span::styled( + if is_selected_row { "> " } else { " " }, + Style::default().fg(theme::ACCENT), + ), + Span::styled( + format!("{} ", if draft.selected { "☑" } else { "☐" }), + Style::default().fg(if draft.selected { + theme::SUCCESS + } else { + theme::TEXT_MUTED + }), + ), + Span::styled( + title, + Style::default() + .fg(theme::TEXT) + .bg(title_bg) + .add_modifier(Modifier::BOLD), + ), + ])); + + if is_selected_row && lines.len() < area.height as usize { + let mut detail_spans = Vec::new(); + let first_desc_line = draft.story.description.lines().next().unwrap_or("").trim(); + if !first_desc_line.is_empty() { + detail_spans.push(Span::styled( + format!(" {first_desc_line}"), + Style::default().fg(theme::TEXT_SECONDARY), + )); + } + if !draft.story.labels.is_empty() { + if !detail_spans.is_empty() { + detail_spans.push(Span::raw(" ")); + } else { + detail_spans.push(Span::raw(" ")); + } + for (index, label) in draft.story.labels.iter().enumerate() { + if index > 0 { + detail_spans.push(Span::raw(" ")); + } + detail_spans.push(Span::styled( + format!(" {label} "), + theme::chip_style(theme::ACCENT_SECONDARY), + )); + } + } + if !detail_spans.is_empty() { + lines.push(Line::from(detail_spans)); + } + } + } + + 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( + 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( + 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); +} + +fn hint_for(app: &App) -> &'static str { + if app.story_preview { + return "Ctrl+P edit. PgUp/PgDn scroll. Esc close."; + } + 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. Enter edit title. Tab source/edit. Ctrl+S create." + } + StoryModalFocus::Edit => { + "Type title. Backspace delete. Enter done. Tab source. Ctrl+S create." + } + } +} From 401657922bd0b08a58ff4bfe0907062a8b2ab19e Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 2 Jul 2026 17:43:19 +0000 Subject: [PATCH 2/4] feat: settings fallback key, side detail panel, clipboard, richer editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from live testing plus mock-fidelity pass: - Settings shortcut: Ctrl+, is untransmittable in legacy terminals, so enable the kitty keyboard protocol when supported and add plain ',' as a universal fallback (footer/help updated) - Issue details now render as a right-hand side panel on wide terminals (>=100 cols) like the design mock, keeping the board visible and clickable; centered modal remains on narrow terminals - Topbar: "● Connected • project @user" status, always-visible search field with placeholder, Settings button - Cards: colored status dot, muted uppercase label subtitle, label pills (fake STATE row removed), taller default cards, "+N more issues" column footer; "● Ready" idle indicator in the status bar - Clipboard: bracketed paste routes into the focused input (editor, notes, story source, search, palette, settings) preserving newlines where supported; Ctrl+C copies the focused field (or selected issue as markdown) via OSC 52 - Editor: vertical scrollbar in modal and panel, Ctrl+T task-checkbox snippet; markdown preview gains h4-h6, horizontal rules, and strikethrough 277 tests pass; fmt clean; clippy unchanged (pre-existing warnings only). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJsFyUeN5xJiw9hQzjx3Gj --- Cargo.toml | 2 +- src/lib.rs | 203 ++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 121 ++++++++++++++++++++++++++- src/ui/board.rs | 46 +++++++--- src/ui/chrome.rs | 55 ++++++++---- src/ui/help.rs | 6 +- src/ui/markdown.rs | 69 ++++++++++++--- src/ui/mod.rs | 96 +++++++++++++++++++-- src/ui/modals.rs | 123 ++++++++++++++++++++++++--- 9 files changed, 658 insertions(+), 63 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d92bbbd..48daa34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ description = "Rust/Ratatui GitLab personal kanban TUI" [dependencies] ratatui = "0.30" -crossterm = "0.29" +crossterm = { version = "0.29", features = ["bracketed-paste"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" diff --git a/src/lib.rs b/src/lib.rs index 217d876..b15add0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,6 +115,7 @@ pub enum ClickTarget { Save, Cancel, CreateSelectedStories, + Settings, SettingsSection { index: usize }, } @@ -207,6 +208,7 @@ pub struct App { pub settings_root: std::path::PathBuf, pub confirm: Option, pub config_dirty: bool, + pub clipboard_request: Option, pub help_scroll: usize, pub connected_user: Option, pub connected_project: Option, @@ -400,6 +402,7 @@ impl App { settings_root: crate::storage::default_root(), confirm: None, config_dirty: false, + clipboard_request: None, help_scroll: 0, connected_user: None, connected_project: None, @@ -1303,6 +1306,103 @@ impl App { self.insert_markdown_template("`code`"); } + pub fn insert_markdown_task(&mut self) { + self.insert_markdown_template("- [ ] "); + } + + // --- Clipboard --- + + /// Route pasted text (bracketed paste) into whatever input has focus. + /// Multi-line text is preserved where the target supports newlines and + /// flattened to spaces where it does not. + pub fn paste(&mut self, text: &str) { + if self.overlay.is_none() && self.search_active { + for ch in text.chars().filter(|ch| !ch.is_control()) { + push_bounded_char(&mut self.search_query, ch, MAX_SEARCH_CHARS); + } + self.align_selection_to_search(); + return; + } + + match self.overlay.clone() { + Some(Overlay::Details | Overlay::NewIssue) => { + if self.editor_preview || self.editor_submit_in_flight { + return; + } + match self.editor_field { + EditorField::Title => { + paste_single_line(&mut self.editor_title, text, MAX_TITLE_CHARS) + } + EditorField::Description => { + paste_multi_line(&mut self.editor_description, text, MAX_DESCRIPTION_CHARS) + } + } + } + Some(Overlay::Notes) => paste_multi_line(&mut self.note_input, text, MAX_NOTE_CHARS), + Some(Overlay::GenerateStories) => match self.story_focus { + StoryModalFocus::Source if !self.story_preview => { + paste_multi_line(&mut self.story_source, text, MAX_STORY_SOURCE_CHARS) + } + StoryModalFocus::Edit => { + if let Some(draft) = self.generated_stories.get_mut(self.story_selected) { + paste_single_line(&mut draft.story.title, text, MAX_TITLE_CHARS); + } + } + _ => {} + }, + Some(Overlay::Palette) => { + for ch in text.chars().filter(|ch| !ch.is_control()) { + self.palette_query.push(ch); + } + self.palette_selected = 0; + } + Some(Overlay::Settings) => { + if let Some(form) = self.settings_form.as_mut() { + if !form.field_is_toggle(form.focused) { + for ch in text.chars().filter(|ch| !ch.is_control()) { + form.input_char(ch); + } + } + } + } + _ => {} + } + } + + /// The text a copy request should target in the current context. + pub fn copy_active_text(&self) -> Option { + let text = match self.overlay.clone() { + Some(Overlay::Details | Overlay::NewIssue) => match self.editor_field { + EditorField::Title => self.editor_title.clone(), + EditorField::Description => self.editor_description.clone(), + }, + Some(Overlay::Notes) => self.note_input.clone(), + Some(Overlay::GenerateStories) => self.story_source.clone(), + None => self + .selected_issue_opt() + .map(|issue| format!("# {}\n\n{}", issue.title, issue.description)) + .unwrap_or_default(), + _ => String::new(), + }; + (!text.is_empty()).then_some(text) + } + + /// Queue an OSC 52 clipboard write (performed by the terminal layer) for + /// the focused text. + pub fn request_copy(&mut self) { + match self.copy_active_text() { + Some(text) => { + let count = text.chars().count(); + self.clipboard_request = Some(text); + self.set_status( + StatusKind::Success, + format!("Copied {count} characters to clipboard"), + ); + } + None => self.set_status(StatusKind::Info, "Nothing to copy"), + } + } + fn insert_markdown_template(&mut self, template: &str) { if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) && self.editor_field == EditorField::Description @@ -1495,6 +1595,7 @@ impl App { ClickTarget::CreateSelectedStories => { self.request_create_selected_stories(); } + ClickTarget::Settings => self.open_settings(), ClickTarget::SettingsSection { index } => { if let Some(form) = self.settings_form.as_mut() { form.section = index.min(crate::settings::SECTION_TITLES.len() - 1); @@ -1926,6 +2027,32 @@ fn push_bounded_char(target: &mut String, ch: char, limit: usize) { } } +/// Paste into a single-line buffer: newlines become spaces, other control +/// characters are dropped. +fn paste_single_line(target: &mut String, text: &str, limit: usize) { + for ch in text.chars() { + let ch = match ch { + '\n' => ' ', + '\r' => continue, + ch if ch.is_control() => continue, + ch => ch, + }; + push_bounded_char(target, ch, limit); + } +} + +/// Paste into a multi-line buffer: keep newlines, drop other control chars. +fn paste_multi_line(target: &mut String, text: &str, limit: usize) { + for ch in text.chars() { + match ch { + '\r' => continue, + '\n' => push_bounded_char(target, '\n', limit), + ch if ch.is_control() => continue, + ch => push_bounded_char(target, ch, limit), + } + } +} + pub(crate) fn truncate_chars(input: &str, limit: usize) -> String { input.chars().take(limit).collect() } @@ -3161,7 +3288,81 @@ mod tests { app.insert_markdown_bold(); app.input_editor_char(' '); app.insert_markdown_link(); + app.insert_markdown_task(); + + assert_eq!(app.editor_description, "**bold** [text](url)- [ ] "); + } + + #[test] + fn paste_preserves_newlines_in_description_and_flattens_title() { + let mut app = App::demo(); + app.open_new_issue(); + + app.paste("Multi\nline title\r\n"); + assert_eq!(app.editor_title, "Multi line title "); + + app.focus_next_editor_field(); + app.paste("# Heading\r\n- [ ] task\n"); + assert_eq!(app.editor_description, "# Heading\n- [ ] task\n"); + } + + #[test] + fn paste_routes_to_story_source_and_search() { + let mut app = App::demo(); + app.open_generate_stories(); + app.paste("## ADR\nBody"); + assert_eq!(app.story_source, "## ADR\nBody"); + + // Preview blocks paste into the source. + app.toggle_story_preview(); + app.paste("more"); + assert_eq!(app.story_source, "## ADR\nBody"); + app.close_overlay(); + + app.start_search(); + app.paste("mouse\n"); + assert_eq!(app.search_query, "mouse"); + } + + #[test] + fn paste_respects_buffer_limits() { + let mut app = App::demo(); + app.open_new_issue(); + + app.paste(&"x".repeat(MAX_TITLE_CHARS + 50)); + + assert_eq!(app.editor_title.chars().count(), MAX_TITLE_CHARS); + } - assert_eq!(app.editor_description, "**bold** [text](url)"); + #[test] + fn copy_targets_focused_editor_field_or_selected_issue() { + let mut app = App::demo(); + assert_eq!( + app.copy_active_text().as_deref(), + Some("# Draft user story\n\nDraft user story details.") + ); + + app.open_details(); + assert_eq!(app.copy_active_text().as_deref(), Some("Draft user story")); + + app.focus_next_editor_field(); + assert_eq!( + app.copy_active_text().as_deref(), + Some("Draft user story details.") + ); + } + + #[test] + fn request_copy_queues_clipboard_write_and_reports_status() { + let mut app = App::demo(); + + app.request_copy(); + + assert!(app.clipboard_request.is_some()); + assert_eq!(app.status_kind, StatusKind::Success); + assert!(app + .story_status + .as_deref() + .is_some_and(|status| status.contains("Copied"))); } } diff --git a/src/main.rs b/src/main.rs index 6a5b757..272164e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,26 @@ use ratatui::{backend::CrosstermBackend, Terminal}; fn main() -> io::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + crossterm::event::EnableBracketedPaste + )?; + // Ctrl+, has no legacy control code; it only arrives on terminals that + // speak the kitty keyboard protocol. Plain ',' is the universal fallback. + let keyboard_enhanced = matches!( + crossterm::terminal::supports_keyboard_enhancement(), + Ok(true) + ); + if keyboard_enhanced { + execute!( + stdout, + crossterm::event::PushKeyboardEnhancementFlags( + crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + ) + )?; + } let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -37,16 +56,56 @@ fn main() -> io::Result<()> { let result = run(&mut terminal, &mut app); disable_raw_mode()?; + if keyboard_enhanced { + let _ = execute!( + terminal.backend_mut(), + crossterm::event::PopKeyboardEnhancementFlags + ); + } execute!( terminal.backend_mut(), LeaveAlternateScreen, - DisableMouseCapture + DisableMouseCapture, + crossterm::event::DisableBracketedPaste )?; terminal.show_cursor()?; result } +/// Write `text` to the system clipboard via OSC 52 — works over SSH and in +/// any terminal that supports the sequence, with no clipboard daemon needed. +fn copy_to_clipboard(text: &str) -> io::Result<()> { + use std::io::Write; + let mut stdout = io::stdout(); + write!(stdout, "\x1b]52;c;{}\x07", base64_encode(text.as_bytes()))?; + stdout.flush() +} + +fn base64_encode(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = chunk.get(1).copied().unwrap_or(0) as u32; + let b2 = chunk.get(2).copied().unwrap_or(0) as u32; + let triple = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[(triple >> 18) as usize & 63] as char); + out.push(ALPHABET[(triple >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + ALPHABET[(triple >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + ALPHABET[triple as usize & 63] as char + } else { + '=' + }); + } + out +} + fn run(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { let mut config = load_config(); prepare_startup(&config, app); @@ -82,6 +141,9 @@ fn run(terminal: &mut Terminal>, app: &mut App) -> if event::poll(Duration::from_millis(40))? { handle_event(event::read()?, app); } + if let Some(text) = app.clipboard_request.take() { + let _ = copy_to_clipboard(&text); + } } } @@ -264,6 +326,7 @@ fn handle_event(event: Event, app: &mut App) { } } } + Event::Paste(text) => app.paste(&text), Event::Mouse(mouse) => match mouse.kind { MouseEventKind::Down(MouseButton::Left) => app.mouse_down_at(mouse.column, mouse.row), MouseEventKind::Drag(MouseButton::Left) => app.mouse_drag_at(mouse.column, mouse.row), @@ -284,6 +347,7 @@ fn handle_board_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { (KeyCode::Char('q'), _) => app.should_quit = true, (KeyCode::Char('k'), KeyModifiers::CONTROL) => app.open_palette(), (KeyCode::Char(','), KeyModifiers::CONTROL) => app.open_settings(), + (KeyCode::Char(','), KeyModifiers::NONE) => app.open_settings(), (KeyCode::Esc, _) => { if !app.search_query.is_empty() { app.end_search(false); @@ -291,6 +355,7 @@ fn handle_board_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { app.cancel_drag(); } } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => app.request_copy(), (KeyCode::Char('c'), _) => app.request_connect(), (KeyCode::Char('n'), _) => app.open_new_issue(), (KeyCode::Char('g'), _) => app.open_generate_stories(), @@ -336,6 +401,12 @@ fn handle_modal_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { app.cancel_ai_request(true) } (KeyCode::Esc, _) => app.close_overlay(), + (KeyCode::Char('c'), KeyModifiers::CONTROL) => app.request_copy(), + (KeyCode::Char('t'), KeyModifiers::CONTROL) + if matches!(app.overlay, Some(Overlay::Details | Overlay::NewIssue)) => + { + app.insert_markdown_task() + } (KeyCode::Char('n'), KeyModifiers::CONTROL) if app.overlay == Some(Overlay::Details) => { app.open_notes() } @@ -417,6 +488,7 @@ fn handle_story_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { (KeyCode::Char(' '), _) => app.toggle_selected_story(), (KeyCode::Backspace, _) => app.backspace_story_input(), (KeyCode::Char('p'), KeyModifiers::CONTROL) => app.toggle_story_preview(), + (KeyCode::Char('c'), KeyModifiers::CONTROL) => app.request_copy(), (KeyCode::PageDown, _) => app.scroll_story_source(4), (KeyCode::PageUp, _) => app.scroll_story_source(-4), (KeyCode::Char('g'), KeyModifiers::CONTROL) => { @@ -1154,4 +1226,49 @@ mod tests { assert!(job_rx.is_none()); assert_eq!(app.busy, None); } + + #[test] + fn base64_encodes_rfc4648_vectors() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + assert_eq!(base64_encode("Lane ✓".as_bytes()), "TGFuZSDinJM="); + } + + #[test] + fn plain_comma_opens_settings() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char(','), KeyModifiers::NONE, &mut app); + + assert_eq!(app.overlay, Some(Overlay::Settings)); + assert!(app.settings_form.is_some()); + } + + #[test] + fn ctrl_c_on_board_copies_selected_issue() { + let mut app = App::demo(); + + handle_board_key(KeyCode::Char('c'), KeyModifiers::CONTROL, &mut app); + + assert!(app + .clipboard_request + .as_deref() + .is_some_and(|text| text.contains("Draft user story"))); + // Plain 'c' still connects. + assert_eq!(app.pending_job, None); + } + + #[test] + fn paste_event_routes_into_focused_editor() { + let mut app = App::demo(); + app.open_new_issue(); + app.focus_next_editor_field(); + + handle_event(Event::Paste("# Pasted\n- [ ] item".into()), &mut app); + + assert_eq!(app.editor_description, "# Pasted\n- [ ] item"); + } } diff --git a/src/ui/board.rs b/src/ui/board.rs index 5ed9ce4..63f7dd7 100644 --- a/src/ui/board.rs +++ b/src/ui/board.rs @@ -100,7 +100,7 @@ fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usi let card_height: u16 = if app.settings.appearance.compact { 3 } else { - 4 + 5 }; let gap: u16 = 1; let stride = card_height + gap; @@ -108,6 +108,7 @@ fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usi 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() { @@ -115,6 +116,7 @@ fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usi 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 @@ -142,6 +144,21 @@ fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usi }, ); } + + 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, + ), + ); + } } if drop_active && inner.width > 4 { @@ -190,7 +207,8 @@ pub(crate) fn render_card( } let mut lines = Vec::new(); - let mut title_spans = Vec::new(); + let dot_color = if issue.blocked { theme::ERROR } else { accent }; + let mut title_spans = vec![Span::styled("● ", Style::default().fg(dot_color))]; if appearance.show_ids { title_spans.push(Span::styled( format!("#{} ", issue.iid), @@ -205,8 +223,21 @@ pub(crate) fn render_card( )); lines.push(Line::from(title_spans)); - if appearance.show_labels && !appearance.compact { - let mut label_spans = Vec::new(); + 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 { label_spans.push(Span::raw(" ")); @@ -219,13 +250,6 @@ pub(crate) fn render_card( lines.push(Line::from(label_spans)); } - if !appearance.compact { - lines.push(Line::from(vec![ - Span::styled("STATE ", Style::default().fg(theme::TEXT_MUTED)), - Span::styled(issue.state.title(), Style::default().fg(accent)), - ])); - } - let content = Rect::new( inner.x + 2, inner.y, diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index eda7a2c..0f09fe4 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -28,23 +28,25 @@ 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("● ", Style::default().fg(theme::SUCCESS)), + 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), Style::default().fg(theme::TEXT_SECONDARY), ), ], (Some(project), None) => vec![ - Span::styled("● ", Style::default().fg(theme::SUCCESS)), + Span::styled("● Connected", Style::default().fg(theme::SUCCESS)), + Span::styled(" • ", Style::default().fg(theme::TEXT_MUTED)), Span::styled( project.path_with_namespace.clone(), Style::default().fg(theme::TEXT_SECONDARY), ), ], (None, Some(user)) => vec![ - Span::styled("● ", Style::default().fg(theme::SUCCESS)), + Span::styled("● Connected", Style::default().fg(theme::SUCCESS)), Span::styled( - format!("@{}", user.username), + format!(" @{}", user.username), Style::default().fg(theme::TEXT_SECONDARY), ), ], @@ -55,7 +57,7 @@ fn connection_summary_spans(app: &App) -> Vec> { .is_some_and(|status| status.starts_with("Config error: missing ")) { vec![Span::styled( - "Config missing — Ctrl+, Settings", + "Config missing — press , for Settings", Style::default().fg(theme::WARNING), )] } else { @@ -88,6 +90,7 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { true, ClickTarget::GenerateStories, ), + ("Settings", 12u16, false, ClickTarget::Settings), ]; let buttons_total_width = buttons.iter().map(|(_, width, _, _)| *width).sum::() + (buttons.len().saturating_sub(1) as u16 * 2); @@ -122,9 +125,9 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { x = x.saturating_add(summary_width + 2); } - if app.search_active || !app.search_query.is_empty() { - let budget = left_limit.saturating_sub(x).min(30); - if budget > 4 { + let budget = left_limit.saturating_sub(x).min(34); + if budget > 12 { + let spans = if app.search_active || !app.search_query.is_empty() { let cursor = if app.search_active { "▌" } else { "" }; let mut spans = vec![ Span::styled("/", Style::default().fg(theme::TEXT_MUTED)), @@ -139,13 +142,19 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { Style::default().fg(theme::TEXT_MUTED), )); } - let width = spans_width(&spans).min(budget); - if width > 0 { - frame.render_widget( - Paragraph::new(Line::from(spans)), - Rect::new(x, inner.y, width, 1), - ); - } + spans + } else { + vec![Span::styled( + " Search issues… / ", + Style::default().fg(theme::TEXT_MUTED).bg(theme::SURFACE), + )] + }; + let width = spans_width(&spans).min(budget); + if width > 0 { + frame.render_widget( + Paragraph::new(Line::from(spans)), + Rect::new(x, inner.y, width, 1), + ); } } @@ -240,10 +249,23 @@ pub fn render_status(frame: &mut Frame, area: Rect, app: &App) { return; } + let shortcuts = footer_shortcuts(app); frame.render_widget( - Paragraph::new(super::shortcut_line(&footer_shortcuts(app))).alignment(Alignment::Center), + Paragraph::new(super::shortcut_line(&shortcuts)).alignment(Alignment::Center), inner, ); + // "● Ready" indicator on the left, only when it won't collide with the + // centered shortcut legend. + let legend_width = super::shortcut_line_width(&shortcuts); + if inner.width > legend_width + 20 { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("● ", Style::default().fg(theme::SUCCESS)), + Span::styled("Ready", Style::default().fg(theme::TEXT_SECONDARY)), + ])), + Rect::new(inner.x + 1, inner.y, 8.min(inner.width), 1), + ); + } } fn render_connecting_gauge(frame: &mut Frame, area: Rect, app: &App) { @@ -273,6 +295,7 @@ pub(crate) fn footer_shortcuts(app: &App) -> Vec<(&'static str, &'static str)> { ("b", "Block"), ("/", "Search"), ("Arrows", "Move"), + (",", "Settings"), ("?", "Help"), ("q", "Quit"), ] diff --git a/src/ui/help.rs b/src/ui/help.rs index 4fdd196..13a9f88 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -50,9 +50,10 @@ fn help_lines() -> Vec> { ("/", "Search issues"), ("?", "This help"), ("Ctrl+K", "Action palette"), - ("Ctrl+,", "Settings"), + (", (comma)", "Settings"), ("c", "Connect"), ("s / r", "Sync"), + ("Ctrl+C", "Copy field / issue to clipboard"), ("q", "Quit"), ], ); @@ -88,7 +89,10 @@ fn help_lines() -> Vec> { ("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"), ], ); push_section( diff --git a/src/ui/markdown.rs b/src/ui/markdown.rs index 57141ad..4652c98 100644 --- a/src/ui/markdown.rs +++ b/src/ui/markdown.rs @@ -69,15 +69,25 @@ pub fn markdown_preview_lines(markdown: &str) -> Vec> { continue; } + if markdown_horizontal_rule(trimmed) { + lines.push(Line::from(vec![Span::styled( + "─".repeat(24), + Style::default().fg(theme::BORDER), + )])); + continue; + } + if let Some((level, heading)) = markdown_heading(trimmed) { - let style = if level == 1 { - Style::default() + let style = match level { + 1 => Style::default() .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD) - } else { - Style::default() + .add_modifier(Modifier::BOLD), + 2 | 3 => Style::default() .fg(theme::TEXT) - .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::BOLD), + _ => Style::default() + .fg(theme::TEXT_SECONDARY) + .add_modifier(Modifier::BOLD), }; lines.push(Line::from(vec![Span::styled( heading.to_uppercase(), @@ -157,13 +167,21 @@ pub fn markdown_preview_lines(markdown: &str) -> Vec> { fn markdown_heading(line: &str) -> Option<(usize, &str)> { let level = line.chars().take_while(|ch| *ch == '#').count(); - if (1..=3).contains(&level) && line.chars().nth(level) == Some(' ') { + if (1..=6).contains(&level) && line.chars().nth(level) == Some(' ') { Some((level, line[level + 1..].trim())) } else { None } } +/// `---`, `***`, or `___` (three or more of one marker) on its own line. +fn markdown_horizontal_rule(line: &str) -> bool { + line.len() >= 3 + && (line.chars().all(|ch| ch == '-') + || line.chars().all(|ch| ch == '*') + || line.chars().all(|ch| ch == '_')) +} + /// Matches a trimmed line starting with `^\d+\. ` (digits, dot, space). /// Returns the digit run and the remaining text. Parsed manually since the /// crate has no regex dependency. @@ -217,6 +235,17 @@ fn markdown_inline_spans(input: &str, base_style: Style) -> Vec> { continue; } + if let Some(stripped) = rest.strip_prefix("~~") { + if let Some(end) = stripped.find("~~") { + spans.push(Span::styled( + stripped[..end].to_string(), + base_style.add_modifier(Modifier::CROSSED_OUT), + )); + rest = &stripped[end + 2..]; + continue; + } + } + if let Some(stripped) = rest.strip_prefix('_') { if let Some(end) = stripped.find('_') { spans.push(Span::styled( @@ -234,7 +263,7 @@ fn markdown_inline_spans(input: &str, base_style: Style) -> Vec> { .char_indices() .skip(1) .find_map(|(index, ch)| { - (ch == '*' || ch == '`' || ch == '[' || ch == '_').then_some(index) + (ch == '*' || ch == '`' || ch == '[' || ch == '_' || ch == '~').then_some(index) }) .unwrap_or(rest.len()); spans.push(Span::styled(rest[..next].to_string(), base_style)); @@ -285,9 +314,27 @@ mod tests { let h3 = markdown_preview_lines("### Sub Sub"); assert_eq!(h3[0].spans[0].style.fg, Some(theme::TEXT)); - // Level 4+ is not a heading at all. - let not_heading = markdown_preview_lines("#### Not a heading"); - assert_eq!(span_text(¬_heading[0], 0), "#### Not a heading"); + // Level 4-6 render as secondary headings. + let h4 = markdown_preview_lines("#### Deep heading"); + assert_eq!(span_text(&h4[0], 0), "DEEP HEADING"); + assert_eq!(h4[0].spans[0].style.fg, Some(theme::TEXT_SECONDARY)); + } + + #[test] + fn horizontal_rule_renders_divider_line() { + for rule in ["---", "----", "***", "___"] { + let lines = markdown_preview_lines(rule); + assert_eq!(span_text(&lines[0], 0), "─".repeat(24), "rule: {rule}"); + assert_eq!(lines[0].spans[0].style.fg, Some(theme::BORDER)); + } + } + + #[test] + fn strikethrough_renders_crossed_out() { + let spans = markdown_inline_spans("~~gone~~ kept", Style::default().fg(theme::TEXT)); + assert_eq!(spans[0].content.to_string(), "gone"); + assert!(spans[0].style.add_modifier.contains(Modifier::CROSSED_OUT)); + assert_eq!(spans[1].content.to_string(), " kept"); } #[test] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8be5889..e020fdc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,6 +20,10 @@ pub mod modals; pub mod settings_ui; pub mod stories; +/// Below this width issue details fall back to a centered modal; at or above +/// it they render as a right-hand side panel next to the board. +pub(crate) const DETAILS_PANEL_MIN_WIDTH: u16 = 100; + pub fn render(frame: &mut Frame, app: &mut App) { app.clear_hitboxes(); let root = frame.area(); @@ -32,11 +36,26 @@ pub fn render(frame: &mut Frame, app: &mut App) { ]) .split(root); + let details_as_panel = + app.overlay == Some(Overlay::Details) && root.width >= DETAILS_PANEL_MIN_WIDTH; + chrome::render_topbar(frame, shell[0], app); - board::render_board(frame, shell[1], app); + if details_as_panel { + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(50), Constraint::Length(44)]) + .split(shell[1]); + board::render_board(frame, split[0], app); + modals::render_details_panel(frame, split[1], app); + } else { + board::render_board(frame, shell[1], app); + } chrome::render_status(frame, shell[2], app); if let Some(overlay) = app.overlay.clone() { + if details_as_panel { + return; + } render_locked_viewport(frame, root); app.clear_hitboxes(); match overlay { @@ -267,7 +286,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(120, 36); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_new_issue(); terminal .draw(|frame| render(frame, &mut app)) @@ -329,6 +348,68 @@ mod tests { assert!(generate > new_issue); } + #[test] + fn topbar_shows_settings_button_and_search_placeholder() { + let mut app = App::demo(); + let screen = render_to_string(120, 36, &mut app); + let topbar_row = screen_row(&screen, 120, 1); + + assert!(topbar_row.contains("Settings"), "topbar: {topbar_row:?}"); + assert!( + topbar_row.contains("Search issues"), + "topbar: {topbar_row:?}" + ); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Settings)); + } + + #[test] + fn details_renders_as_side_panel_on_wide_terminal() { + let mut app = App::demo(); + app.open_details(); + + let screen = render_to_string(120, 36, &mut app); + + // Board stays visible next to the panel. + assert!(screen.contains("TODO 09")); + assert!(screen.contains("Issue #101")); + assert!(screen.contains("Title")); + // Board hitboxes remain live alongside the panel's Save/Cancel. + assert!(app + .hitboxes + .iter() + .any(|hitbox| matches!(hitbox.target, ClickTarget::Issue { .. }))); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::Save)); + } + + #[test] + fn details_falls_back_to_modal_on_narrow_terminal() { + let mut app = App::demo(); + app.open_details(); + + let screen = render_to_string(80, 36, &mut app); + + assert!(screen.contains("Issue #101")); + assert!(app + .hitboxes + .iter() + .all(|hitbox| matches!(hitbox.target, ClickTarget::Save | ClickTarget::Cancel))); + } + + #[test] + fn board_shows_more_issues_footer_when_column_overflows() { + let mut app = App::demo(); + + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("more issue"), "screen: missing +N footer"); + } + #[test] fn topbar_shows_search_query_and_match_count() { let mut app = App::demo(); @@ -688,7 +769,6 @@ mod tests { assert!(screen.contains("┌")); assert!(screen.contains("─")); assert!(screen.contains("│")); - assert!(!screen.contains("+")); } #[test] @@ -837,7 +917,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(120, 36); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_new_issue(); terminal .draw(|frame| render(frame, &mut app)) @@ -937,7 +1017,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(100, 32); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_new_issue(); terminal .draw(|frame| render(frame, &mut app)) @@ -950,7 +1030,7 @@ mod tests { .map(|cell| cell.symbol()) .collect(); - assert!(screen.contains("Issue #101")); + assert!(screen.contains("Issue #")); assert!(app .hitboxes .iter() @@ -1013,7 +1093,7 @@ mod tests { #[test] fn ai_editor_modal_renders_labels_and_shortcuts() { let mut app = App::demo(); - app.open_details(); + app.open_new_issue(); app.editor_labels = vec!["backend".into()]; let screen = render_to_string(120, 36, &mut app); @@ -1030,7 +1110,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(120, 36); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_new_issue(); app.request_improve_description(); terminal diff --git a/src/ui/modals.rs b/src/ui/modals.rs index b8b26a0..17921ff 100644 --- a/src/ui/modals.rs +++ b/src/ui/modals.rs @@ -2,7 +2,10 @@ use ratatui::{ prelude::*, - widgets::{Block, BorderType, Borders, Paragraph, Wrap}, + widgets::{ + Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, + Wrap, + }, }; use crate::theme; @@ -23,6 +26,112 @@ 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 }) + .scroll(( + app.editor_description_scroll.min(u16::MAX as usize) as u16, + 0, + )), + 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, + ); +} + +/// Issue details as a right-hand side panel (wide terminals). Shares the +/// editor content and Save/Cancel semantics with the centered modal; only +/// the framing differs. +pub fn render_details_panel(frame: &mut Frame, area: Rect, app: &mut App) { + let Some(issue) = app.selected_issue_opt() else { + return; + }; + let iid = issue.iid; + let state = issue.state; + let accent = super::board::state_color(state); + + let block = Block::new() + .title(format!(" Issue #{iid} ")) + .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)); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.height < 4 || inner.width < 10 { + return; + } + + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + format!(" {} ", state.title()), + Style::default() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD), + )])), + Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1), + ); + + let body = Rect::new( + inner.x + 1, + inner.y + 2, + inner.width.saturating_sub(2), + inner.height.saturating_sub(4), + ); + let lines = issue_editor_lines(app); + let total_lines = lines.len(); + frame.render_widget( + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll(( + app.editor_description_scroll.min(u16::MAX as usize) as u16, + 0, + )), + body, + ); + render_editor_scrollbar(frame, body, total_lines, app.editor_description_scroll); + + let footer_y = inner.y + inner.height.saturating_sub(1); + let save = Rect::new(inner.x + 1, footer_y, 10, 1); + let cancel = Rect::new(inner.x + 13, footer_y, 8, 1); + render_button(frame, save, "Save", theme::button_primary_style()); + render_button(frame, cancel, "Cancel", theme::button_secondary_style()); + app.add_hitbox(save, ClickTarget::Save); + app.add_hitbox(cancel, ClickTarget::Cancel); +} + +/// 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> { let title_text = app.editor_title.as_str(); let description_text = app.editor_description.as_str(); let title_active = app.editor_field == EditorField::Title; @@ -114,17 +223,7 @@ pub fn render_issue_modal(frame: &mut Frame, area: Rect, app: &mut App, is_new: Style::default().fg(theme::TEXT_MUTED), )])); - frame.render_widget( - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .scroll(( - app.editor_description_scroll.min(u16::MAX as usize) as u16, - 0, - )), - chrome.body, - ); - - render_modal_footer(frame, app, chrome.rect, chrome.footer); + lines } pub fn render_notes_modal(frame: &mut Frame, area: Rect, app: &mut App) { From 448985e047dfd13498b75f13cade33a04dd52dcd Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 2 Jul 2026 18:01:02 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20mock-fidelity=20pass=20=E2=80=94=20?= =?UTF-8?q?read-only=20details=20view,=20widget-based=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the details surface and move rendering onto ratatui widgets per design-mock feedback: - Details is now a read-only issue view (side panel on wide terminals, modal on narrow): title with status dot, state chip, colored label pills, due date, linked source, rendered markdown description with acceptance-criteria checkboxes, and Edit / Move / Close actions. Editing moved to a dedicated EditIssue overlay ('e' or Enter). - 'm' moves the selected issue one column right (GitLab transition when connected, local move offline) - Rounded borders everywhere (ratatui border::ROUNDED); Tabs widget for Settings sections and story Edit/Preview/Generated tabs; settings text fields render as bordered input Blocks with focus/error borders and inline validation in the title; toggles as ☑/☐ rows - Buttons render as half-block pills instead of colored text - Board cards: colored status dot, per-label deterministic pill colors, uppercase label subtitle, "+N more issues" column footer, "+" new-issue affordance in column headers - Topbar: "● Connected • project @user", "⟳ Synced Nm ago" after sync, Help and Settings buttons; "● Ready" idle indicator in the status bar - Generate Stories: Select All button + Ctrl+A toggle-all - Help overlay: two-column shortcut layout - Mouse capture releases automatically inside overlays so terminal-native text selection/copy works in text areas; board keeps drag & drop 278 tests pass; fmt clean; clippy unchanged (pre-existing warnings only). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJsFyUeN5xJiw9hQzjx3Gj --- src/jobs.rs | 11 +- src/lib.rs | 124 +++++++++++++++++++---- src/main.rs | 65 +++++++++--- src/theme.rs | 13 +-- src/ui/board.rs | 28 +++++- src/ui/chrome.rs | 25 +++-- src/ui/help.rs | 37 ++++--- src/ui/mod.rs | 83 ++++++++++----- src/ui/modals.rs | 193 +++++++++++++++++++++++++++++------ src/ui/settings_ui.rs | 227 ++++++++++++++++++++++-------------------- src/ui/stories.rs | 78 ++++++--------- 11 files changed, 611 insertions(+), 273 deletions(-) diff --git a/src/jobs.rs b/src/jobs.rs index 942cfaf..7730d27 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -882,7 +882,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { app.upsert_issue_record(&issue); if app.editor_submit_in_flight { app.editor_submit_in_flight = false; - if app.overlay == Some(crate::Overlay::Details) { + if app.overlay == Some(crate::Overlay::EditIssue) { app.close_overlay(); } } @@ -999,6 +999,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { let skipped = sync.skipped.len(); app.open_assigned_set = sync.open_assigned_set; app.has_synced = true; + app.last_synced = Some(std::time::Instant::now()); app.replace_issue_records(&sync.issues); app.set_status( crate::StatusKind::Success, @@ -2577,7 +2578,7 @@ mod tests { #[test] fn issue_updated_success_closes_pending_details_modal() { let mut app = crate::App::demo(); - app.open_details(); + app.open_edit_issue(); app.editor_submit_in_flight = true; apply_job_result( @@ -2596,12 +2597,12 @@ mod tests { JobResult::IssueUpdated(Err("update failed".into())), ] { let mut app = crate::App::demo(); - app.open_details(); + app.open_edit_issue(); app.editor_submit_in_flight = true; apply_job_result(&mut app, result); - assert_eq!(app.overlay, Some(crate::Overlay::Details)); + assert_eq!(app.overlay, Some(crate::Overlay::EditIssue)); assert!(!app.editor_submit_in_flight); } } @@ -2609,7 +2610,7 @@ mod tests { #[test] fn ai_editor_stale_result_does_not_overwrite_after_cancel() { let mut app = crate::App::demo(); - app.open_details(); + app.open_edit_issue(); app.editor_description = "keep me".into(); app.request_improve_description(); let generation_id = app.ai_generation_id; diff --git a/src/lib.rs b/src/lib.rs index b15add0..13ca338 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,12 +54,16 @@ pub struct Issue { pub state: LaneState, pub labels: Vec, pub blocked: bool, + pub due_date: Option, pub source: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum Overlay { + /// Read-only issue view (side panel on wide terminals). Details, + /// Editable form for the selected issue. + EditIssue, Notes, NewIssue, GenerateStories, @@ -115,8 +119,12 @@ pub enum ClickTarget { Save, Cancel, CreateSelectedStories, + SelectAllStories, Settings, SettingsSection { index: usize }, + Help, + EditIssue, + MoveIssueRight, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -209,6 +217,7 @@ pub struct App { pub confirm: Option, pub config_dirty: bool, pub clipboard_request: Option, + pub last_synced: Option, pub help_scroll: usize, pub connected_user: Option, pub connected_project: Option, @@ -403,6 +412,7 @@ impl App { confirm: None, config_dirty: false, clipboard_request: None, + last_synced: None, help_scroll: 0, connected_user: None, connected_project: None, @@ -578,6 +588,57 @@ impl App { self.overlay = Some(Overlay::Details); } + /// Open the editable form for the selected issue (from the read-only + /// details view or directly from the board). + pub fn open_edit_issue(&mut self) { + self.open_details(); + if self.overlay == Some(Overlay::Details) { + self.overlay = Some(Overlay::EditIssue); + } + } + + /// Move the selected issue one column to the right (queues a GitLab + /// transition when connected, moves locally otherwise). + pub fn move_selected_issue_right(&mut self) { + let column = self.active_column; + if column + 1 >= self.columns.len() { + return; + } + let Some(issue) = self.selected_issue_opt() else { + return; + }; + if self.connected_project.is_some() { + self.pending_job = Some(crate::jobs::JobCommand::MoveIssue { + iid: issue.iid, + from: board_state_for(column), + to: board_state_for(column + 1), + }); + self.set_status(StatusKind::Info, "Move pending"); + } else { + let index = self.selected[column]; + let mut issue = self.columns[column].remove(index); + issue.state = state_for(column + 1); + self.columns[column + 1].push(issue); + self.selected[column] = + self.selected[column].min(self.columns[column].len().saturating_sub(1)); + self.active_column = column + 1; + self.selected[column + 1] = self.columns[column + 1].len() - 1; + self.ensure_selected_visible(column + 1, 4); + } + } + + /// Select every generated story draft, or clear the selection when all + /// are already selected. + pub fn toggle_select_all_stories(&mut self) { + if self.overlay != Some(Overlay::GenerateStories) || self.generated_stories.is_empty() { + return; + } + let all_selected = self.generated_stories.iter().all(|draft| draft.selected); + for draft in &mut self.generated_stories { + draft.selected = !all_selected; + } + } + pub fn open_notes(&mut self) { let Some(iid) = self.selected_issue_opt().map(|issue| issue.iid) else { self.overlay = None; @@ -761,7 +822,7 @@ impl App { // --- Editor input --- pub fn input_editor_char(&mut self, ch: char) { - if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) + if matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) && !ch.is_control() && !self.editor_preview && !self.editor_submit_in_flight @@ -775,7 +836,7 @@ impl App { } pub fn input_editor_newline(&mut self) { - if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) + if matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) && self.editor_field == EditorField::Description && !self.editor_preview && !self.editor_submit_in_flight @@ -785,7 +846,10 @@ impl App { } pub fn scroll_editor_description(&mut self, delta: i16) { - if !matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) { + if !matches!( + self.overlay, + Some(Overlay::Details | Overlay::EditIssue | Overlay::NewIssue) + ) { return; } let max_scroll = self.editor_description_scroll_max(); @@ -800,10 +864,12 @@ impl App { } fn editor_description_scroll_max(&self) -> usize { + // The read-only details view always renders the markdown preview. + let preview = self.editor_preview || self.overlay == Some(Overlay::Details); crate::ui::markdown::description_lines( &self.editor_description, self.editor_field == EditorField::Description, - self.editor_preview, + preview, ) .len() .saturating_sub(1) @@ -1097,7 +1163,7 @@ impl App { } pub fn backspace_editor(&mut self) { - if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) + if matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) && !self.editor_preview && !self.editor_submit_in_flight { @@ -1112,7 +1178,7 @@ impl App { } pub fn focus_next_editor_field(&mut self) { - if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) { + if matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) { self.editor_preview = false; self.editor_field = match self.editor_field { EditorField::Title => EditorField::Description, @@ -1283,7 +1349,7 @@ impl App { } pub fn toggle_markdown_preview(&mut self) { - if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) + if matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) && self.editor_field == EditorField::Description { self.editor_preview = !self.editor_preview; @@ -1325,7 +1391,7 @@ impl App { } match self.overlay.clone() { - Some(Overlay::Details | Overlay::NewIssue) => { + Some(Overlay::EditIssue | Overlay::NewIssue) => { if self.editor_preview || self.editor_submit_in_flight { return; } @@ -1372,13 +1438,13 @@ impl App { /// The text a copy request should target in the current context. pub fn copy_active_text(&self) -> Option { let text = match self.overlay.clone() { - Some(Overlay::Details | Overlay::NewIssue) => match self.editor_field { + Some(Overlay::EditIssue | Overlay::NewIssue) => match self.editor_field { EditorField::Title => self.editor_title.clone(), EditorField::Description => self.editor_description.clone(), }, Some(Overlay::Notes) => self.note_input.clone(), Some(Overlay::GenerateStories) => self.story_source.clone(), - None => self + Some(Overlay::Details) | None => self .selected_issue_opt() .map(|issue| format!("# {}\n\n{}", issue.title, issue.description)) .unwrap_or_default(), @@ -1404,7 +1470,7 @@ impl App { } fn insert_markdown_template(&mut self, template: &str) { - if matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) + if matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) && self.editor_field == EditorField::Description && !self.editor_preview && !self.editor_submit_in_flight @@ -1417,7 +1483,7 @@ impl App { let title = truncate_chars(self.editor_title.trim(), MAX_TITLE_CHARS); let description = truncate_chars(self.editor_description.trim(), MAX_DESCRIPTION_CHARS); match self.overlay.clone() { - Some(Overlay::Details) => { + Some(Overlay::EditIssue) => { if title.is_empty() { self.close_overlay(); } else if self.connected_project.is_some() { @@ -1480,6 +1546,7 @@ impl App { self.editor_labels.clone() }, blocked: false, + due_date: None, source: None, }); self.active_column = 0; @@ -1516,6 +1583,7 @@ impl App { } Some(Overlay::Settings) => self.save_settings(), Some(Overlay::Confirm) => self.confirm_accept(), + Some(Overlay::Details) => self.open_edit_issue(), Some(Overlay::Palette | Overlay::GenerateStories | Overlay::Help) => { self.close_overlay() } @@ -1596,6 +1664,10 @@ impl App { self.request_create_selected_stories(); } ClickTarget::Settings => self.open_settings(), + ClickTarget::Help => self.open_help(), + ClickTarget::EditIssue => self.open_edit_issue(), + ClickTarget::MoveIssueRight => self.move_selected_issue_right(), + ClickTarget::SelectAllStories => self.toggle_select_all_stories(), ClickTarget::SettingsSection { index } => { if let Some(form) = self.settings_form.as_mut() { form.section = index.min(crate::settings::SECTION_TITLES.len() - 1); @@ -1684,7 +1756,7 @@ impl App { pub fn wheel_at(&mut self, x: u16, y: u16, delta: i16) { match self.overlay { - Some(Overlay::Details | Overlay::NewIssue) => { + Some(Overlay::Details | Overlay::EditIssue | Overlay::NewIssue) => { self.scroll_editor_description(delta); return; } @@ -1761,7 +1833,7 @@ impl App { } fn editor_request_payload(&self) -> Option<(String, String)> { - if !matches!(self.overlay, Some(Overlay::Details | Overlay::NewIssue)) { + if !matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) { return None; } Some((self.editor_title.clone(), self.editor_description.clone())) @@ -2073,6 +2145,7 @@ fn issue(iid: u64, title: &str, state: LaneState, labels: &[&'static str], block state, labels: labels.iter().map(|label| (*label).to_string()).collect(), blocked, + due_date: None, source: None, } } @@ -2085,6 +2158,7 @@ fn issue_from_record(record: &crate::model::IssueRecord) -> Issue { state: lane_state_from_board(record.state), labels: record.labels.clone(), blocked: record.flags.contains(&crate::model::Flag::Blocked), + due_date: record.due_date.clone(), source: record.source, } } @@ -2984,7 +3058,7 @@ mod tests { let mut app = App::demo(); app.move_issue_down(); - app.open_details(); + app.open_edit_issue(); app.clear_editor(); for ch in "Tune modal flow".chars() { app.input_editor_char(ch); @@ -3002,7 +3076,7 @@ mod tests { app.move_issue_down(); let title = app.selected_issue().title.clone(); - app.open_details(); + app.open_edit_issue(); app.focus_next_editor_field(); app.clear_editor(); for ch in "Description now editable.".chars() { @@ -3045,14 +3119,14 @@ mod tests { app.move_issue_down(); let original_title = app.selected_issue().title.clone(); - app.open_details(); + app.open_edit_issue(); app.clear_editor(); for ch in "Tune modal flow".chars() { app.input_editor_char(ch); } app.save_overlay(); - assert_eq!(app.overlay, Some(Overlay::Details)); + assert_eq!(app.overlay, Some(Overlay::EditIssue)); assert_eq!(app.selected_issue().title, original_title); assert!(app.editor_submit_in_flight); assert_eq!( @@ -3078,14 +3152,14 @@ mod tests { }); app.move_issue_down(); - app.open_details(); + app.open_edit_issue(); app.clear_editor(); for ch in "Tune modal flow".chars() { app.input_editor_char(ch); } app.save_overlay(); - assert_eq!(app.overlay, Some(Overlay::Details)); + assert_eq!(app.overlay, Some(Overlay::EditIssue)); assert!(app.editor_submit_in_flight); assert_eq!(app.editor_title, "Tune modal flow"); } @@ -3282,7 +3356,7 @@ mod tests { fn markdown_shortcuts_insert_templates_into_description() { let mut app = App::demo(); - app.open_details(); + app.open_edit_issue(); app.focus_next_editor_field(); app.clear_editor(); app.insert_markdown_bold(); @@ -3342,7 +3416,15 @@ mod tests { Some("# Draft user story\n\nDraft user story details.") ); + // Read-only details view copies the whole issue as markdown. app.open_details(); + assert_eq!( + app.copy_active_text().as_deref(), + Some("# Draft user story\n\nDraft user story details.") + ); + + // The editor copies the focused field. + app.open_edit_issue(); assert_eq!(app.copy_active_text().as_deref(), Some("Draft user story")); app.focus_next_editor_field(); diff --git a/src/main.rs b/src/main.rs index 272164e..583d7f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,7 +53,12 @@ fn main() -> io::Result<()> { let mut terminal = Terminal::new(backend)?; let mut app = App::demo(); - let result = run(&mut terminal, &mut app); + let mut mouse_captured = true; + let result = run(&mut terminal, &mut app, &mut mouse_captured); + if !mouse_captured { + // Re-enable so the teardown disable below leaves a consistent state. + let _ = execute!(terminal.backend_mut(), EnableMouseCapture); + } disable_raw_mode()?; if keyboard_enhanced { @@ -106,7 +111,11 @@ fn base64_encode(input: &[u8]) -> String { out } -fn run(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { +fn run( + terminal: &mut Terminal>, + app: &mut App, + mouse_captured: &mut bool, +) -> io::Result<()> { let mut config = load_config(); prepare_startup(&config, app); let mut job_rx: Option> = None; @@ -138,6 +147,19 @@ fn run(terminal: &mut Terminal>, app: &mut App) -> return Ok(()); } + // Board mode captures the mouse for drag & drop; inside overlays the + // capture is released so the terminal's native text selection and + // copy work in text areas. + let want_capture = app.overlay.is_none(); + if want_capture != *mouse_captured { + if want_capture { + execute!(terminal.backend_mut(), EnableMouseCapture)?; + } else { + execute!(terminal.backend_mut(), DisableMouseCapture)?; + } + *mouse_captured = want_capture; + } + if event::poll(Duration::from_millis(40))? { handle_event(event::read()?, app); } @@ -321,6 +343,7 @@ fn handle_event(event: Event, app: &mut App) { Some(Overlay::Settings) => handle_settings_key(key.code, key.modifiers, app), Some(Overlay::Help) => handle_help_key(key.code, key.modifiers, app), Some(Overlay::Confirm) => handle_confirm_key(key.code, key.modifiers, app), + Some(Overlay::Details) => handle_details_key(key.code, key.modifiers, app), Some(_) => handle_modal_key(key.code, key.modifiers, app), None => handle_board_key(key.code, key.modifiers, app), } @@ -392,10 +415,27 @@ fn handle_search_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { } } +/// Keys for the read-only issue details view (side panel or modal). +fn handle_details_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { + match (code, modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('x'), _) => app.close_overlay(), + (KeyCode::Char('c'), KeyModifiers::CONTROL) => app.request_copy(), + (KeyCode::Char('e'), _) | (KeyCode::Enter, _) => app.open_edit_issue(), + (KeyCode::Char('m'), _) => app.move_selected_issue_right(), + (KeyCode::Char('b'), _) => app.toggle_blocked(), + (KeyCode::Char('n'), KeyModifiers::CONTROL) => app.open_notes(), + (KeyCode::Down, _) => app.scroll_editor_description(1), + (KeyCode::Up, _) => app.scroll_editor_description(-1), + (KeyCode::PageDown, _) => app.scroll_editor_description(4), + (KeyCode::PageUp, _) => app.scroll_editor_description(-4), + _ => {} + } +} + fn handle_modal_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { match (code, modifiers) { (KeyCode::Esc, _) - if matches!(app.overlay, Some(Overlay::Details | Overlay::NewIssue)) + if matches!(app.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) && app.ai_job_in_flight => { app.cancel_ai_request(true) @@ -403,25 +443,25 @@ fn handle_modal_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { (KeyCode::Esc, _) => app.close_overlay(), (KeyCode::Char('c'), KeyModifiers::CONTROL) => app.request_copy(), (KeyCode::Char('t'), KeyModifiers::CONTROL) - if matches!(app.overlay, Some(Overlay::Details | Overlay::NewIssue)) => + if matches!(app.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) => { app.insert_markdown_task() } - (KeyCode::Char('n'), KeyModifiers::CONTROL) if app.overlay == Some(Overlay::Details) => { + (KeyCode::Char('n'), KeyModifiers::CONTROL) if app.overlay == Some(Overlay::EditIssue) => { app.open_notes() } (KeyCode::Char('a'), KeyModifiers::CONTROL) - if matches!(app.overlay, Some(Overlay::Details | Overlay::NewIssue)) => + if matches!(app.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) => { app.request_improve_description() } (KeyCode::Char('y'), KeyModifiers::CONTROL) - if matches!(app.overlay, Some(Overlay::Details | Overlay::NewIssue)) => + if matches!(app.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) => { app.request_generate_criteria() } (KeyCode::Char('l'), KeyModifiers::CONTROL) - if matches!(app.overlay, Some(Overlay::Details | Overlay::NewIssue)) => + if matches!(app.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) => { app.request_suggest_labels() } @@ -489,6 +529,7 @@ fn handle_story_key(code: KeyCode, modifiers: KeyModifiers, app: &mut App) { (KeyCode::Backspace, _) => app.backspace_story_input(), (KeyCode::Char('p'), KeyModifiers::CONTROL) => app.toggle_story_preview(), (KeyCode::Char('c'), KeyModifiers::CONTROL) => app.request_copy(), + (KeyCode::Char('a'), KeyModifiers::CONTROL) => app.toggle_select_all_stories(), (KeyCode::PageDown, _) => app.scroll_story_source(4), (KeyCode::PageUp, _) => app.scroll_story_source(-4), (KeyCode::Char('g'), KeyModifiers::CONTROL) => { @@ -767,7 +808,7 @@ mod tests { }); app.open_details(); - handle_modal_key(KeyCode::Char('n'), KeyModifiers::CONTROL, &mut app); + handle_details_key(KeyCode::Char('n'), KeyModifiers::CONTROL, &mut app); assert_eq!(app.overlay, Some(Overlay::Notes)); assert_eq!( @@ -875,7 +916,7 @@ mod tests { description: None, text_color: "#ffffff".into(), }]; - app.open_details(); + app.open_edit_issue(); handle_modal_key(KeyCode::Char('a'), KeyModifiers::CONTROL, &mut app); assert!(matches!( @@ -910,12 +951,12 @@ mod tests { #[test] fn ai_editor_escape_cancels_in_flight_ai_without_closing_modal() { let mut app = App::demo(); - app.open_details(); + app.open_edit_issue(); app.request_improve_description(); handle_modal_key(KeyCode::Esc, KeyModifiers::NONE, &mut app); - assert_eq!(app.overlay, Some(Overlay::Details)); + assert_eq!(app.overlay, Some(Overlay::EditIssue)); assert_eq!(app.pending_job, None); assert!(!app.ai_job_in_flight); assert_eq!(app.ai_generation_id, 2); diff --git a/src/theme.rs b/src/theme.rs index f05e68c..dd6bbc4 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -38,18 +38,9 @@ pub fn spinner_frame(tick: u16) -> &'static str { SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()] } -/// Single-line solid border set used everywhere (no doubles, no ASCII). +/// Rounded single-line border set used everywhere (no doubles, no ASCII). pub fn solid_border() -> border::Set<'static> { - border::Set { - top_left: "┌", - top_right: "┐", - bottom_left: "└", - bottom_right: "┘", - vertical_left: "│", - vertical_right: "│", - horizontal_top: "─", - horizontal_bottom: "─", - } + border::ROUNDED } pub fn button_primary_style() -> Style { diff --git a/src/ui/board.rs b/src/ui/board.rs index 63f7dd7..fc194e4 100644 --- a/src/ui/board.rs +++ b/src/ui/board.rs @@ -17,6 +17,23 @@ pub(crate) fn state_color(state: LaneState) -> Color { } } +/// Deterministic per-label accent so pills read like the design mock: +/// the same label always gets the same color. +pub(crate) fn label_color(label: &str) -> Color { + const PALETTE: [Color; 5] = [ + theme::ACCENT_SECONDARY, + theme::ACCENT, + theme::SUCCESS, + theme::INFO, + theme::WARNING, + ]; + let mut hash: u32 = 0; + for byte in label.bytes() { + hash = hash.wrapping_mul(31).wrapping_add(u32::from(byte)); + } + PALETTE[hash as usize % PALETTE.len()] +} + fn drag_over_column(app: &App, column: usize) -> bool { matches!( app.drag.as_ref().and_then(|drag| drag.over), @@ -90,6 +107,15 @@ fn render_column(frame: &mut Frame, area: Rect, app: &mut App, column_index: usi column: column_index, }, ); + // "+" affordance in the column header (mock parity): click to create. + if area.width > 8 { + let plus = Rect::new(area.x + area.width.saturating_sub(4), area.y, 3, 1); + frame.render_widget( + Paragraph::new(" + ").style(Style::default().fg(accent)), + plus, + ); + app.add_hitbox(plus, ClickTarget::NewIssue); + } let rows = app.visible_rows(column_index); if app.columns[column_index].is_empty() { @@ -244,7 +270,7 @@ pub(crate) fn render_card( } label_spans.push(Span::styled( format!(" {label} "), - theme::chip_style(theme::TEXT_SECONDARY), + theme::chip_style(label_color(label)), )); } lines.push(Line::from(label_spans)); diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 0f09fe4..d0b159c 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -81,16 +81,17 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { frame.render_widget(block, area); let buttons = [ - ("Connect", 11u16, false, ClickTarget::Connect), - ("Sync", 8u16, false, ClickTarget::SyncAll), - ("New Issue", 12u16, false, ClickTarget::NewIssue), + ("Connect", 9u16, false, ClickTarget::Connect), + ("Sync", 6u16, false, ClickTarget::SyncAll), + ("New Issue", 11u16, false, ClickTarget::NewIssue), ( "Generate Stories", 18u16, true, ClickTarget::GenerateStories, ), - ("Settings", 12u16, false, ClickTarget::Settings), + ("? Help", 8u16, false, ClickTarget::Help), + ("Settings", 10u16, false, ClickTarget::Settings), ]; let buttons_total_width = buttons.iter().map(|(_, width, _, _)| *width).sum::() + (buttons.len().saturating_sub(1) as u16 * 2); @@ -105,7 +106,7 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { let mut x = inner.x; - let brand = " Lane "; + let brand = " ▣ Lane "; let brand_width = (brand.chars().count() as u16).min(left_limit.saturating_sub(x)); if brand_width > 0 { frame.render_widget( @@ -115,7 +116,19 @@ pub fn render_topbar(frame: &mut Frame, area: Rect, app: &mut App) { x = x.saturating_add(brand_width + 1); } - let summary_spans = connection_summary_spans(app); + let mut summary_spans = connection_summary_spans(app); + if let Some(synced) = app.last_synced { + let minutes = synced.elapsed().as_secs() / 60; + let ago = if minutes == 0 { + "⟳ Synced just now".to_string() + } else { + format!("⟳ Synced {minutes}m ago") + }; + summary_spans.push(Span::styled( + format!(" • {ago}"), + Style::default().fg(theme::TEXT_MUTED), + )); + } let summary_width = spans_width(&summary_spans).min(left_limit.saturating_sub(x)); if summary_width > 0 { frame.render_widget( diff --git a/src/ui/help.rs b/src/ui/help.rs index 13a9f88..3cb7d95 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -20,11 +20,23 @@ pub fn render_help_modal(frame: &mut Frame, area: Rect, app: &mut App) { .split(body); let (content_area, hint_row) = (rows[0], rows[1]); + // Two-column shortcut layout, like the design mock. + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(content_area); + let (left, right) = help_columns(); frame.render_widget( - Paragraph::new(help_lines()) + Paragraph::new(left) .wrap(Wrap { trim: false }) .scroll((app.help_scroll as u16, 0)), - content_area, + columns[0], + ); + frame.render_widget( + Paragraph::new(right) + .wrap(Wrap { trim: false }) + .scroll((app.help_scroll as u16, 0)), + columns[1], ); frame.render_widget( @@ -40,11 +52,11 @@ pub fn render_help_modal(frame: &mut Frame, area: Rect, app: &mut App) { app.add_hitbox(close, ClickTarget::Cancel); } -fn help_lines() -> Vec> { - let mut lines = Vec::new(); +fn help_columns() -> (Vec>, Vec>) { + let mut left = Vec::new(); push_section( - &mut lines, + &mut left, "Global", &[ ("/", "Search issues"), @@ -58,7 +70,7 @@ fn help_lines() -> Vec> { ], ); push_section( - &mut lines, + &mut left, "Board", &[ ("\u{2191}\u{2193} / j k", "Move between issues"), @@ -70,8 +82,9 @@ fn help_lines() -> Vec> { ("Esc", "Clear search"), ], ); + let mut right = Vec::new(); push_section( - &mut lines, + &mut right, "Modals", &[ ("Tab", "Next field"), @@ -81,7 +94,7 @@ fn help_lines() -> Vec> { ], ); push_section( - &mut lines, + &mut right, "Issue editor", &[ ("Ctrl+A", "AI improve description"), @@ -96,7 +109,7 @@ fn help_lines() -> Vec> { ], ); push_section( - &mut lines, + &mut right, "Generate Stories", &[ ("Ctrl+G", "Generate"), @@ -106,13 +119,13 @@ fn help_lines() -> Vec> { ], ); - lines.push(Line::raw("")); - lines.push(Line::from(Span::styled( + left.push(Line::raw("")); + left.push(Line::from(Span::styled( "Tip: Press ? anytime to open this help.", Style::default().fg(theme::TEXT_MUTED), ))); - lines + (left, right) } fn push_section(lines: &mut Vec>, title: &str, rows: &[(&str, &str)]) { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e020fdc..51400cc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -60,7 +60,8 @@ pub fn render(frame: &mut Frame, app: &mut App) { app.clear_hitboxes(); match overlay { Overlay::Palette => modals::render_palette(frame, root, app), - Overlay::Details => modals::render_issue_modal(frame, root, app, false), + Overlay::Details => modals::render_details_view_modal(frame, root, app), + Overlay::EditIssue => modals::render_issue_modal(frame, root, app, false), Overlay::Notes => modals::render_notes_modal(frame, root, app), Overlay::NewIssue => modals::render_issue_modal(frame, root, app, true), Overlay::GenerateStories => stories::render_generate_stories_modal(frame, root, app), @@ -150,13 +151,24 @@ pub(crate) fn modal_frame( ModalChrome { rect, body, footer } } +/// 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) { - frame.render_widget( - Paragraph::new(label) - .alignment(Alignment::Center) - .style(style), - area, - ); + 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(); + let pad = inner_width.saturating_sub(text.chars().count()); + let left_pad = pad / 2; + let right_pad = pad - left_pad; + let line = Line::from(vec![ + Span::styled("▐", Style::default().fg(cap_color)), + Span::styled( + format!("{}{text}{}", " ".repeat(left_pad), " ".repeat(right_pad)), + style, + ), + Span::styled("▌", Style::default().fg(cap_color)), + ]); + frame.render_widget(Paragraph::new(line), area); } pub(crate) fn shortcut_line_width(shortcuts: &[(&str, &str)]) -> u16 { @@ -239,6 +251,7 @@ mod tests { state, labels: vec!["ui".into(), "polish".into()], blocked, + due_date: None, source: None, } } @@ -372,11 +385,13 @@ mod tests { let screen = render_to_string(120, 36, &mut app); - // Board stays visible next to the panel. + // Board stays visible next to the read-only view panel. assert!(screen.contains("TODO 09")); assert!(screen.contains("Issue #101")); - assert!(screen.contains("Title")); - // Board hitboxes remain live alongside the panel's Save/Cancel. + assert!(screen.contains("Description")); + assert!(screen.contains("Edit")); + assert!(screen.contains("Move")); + // Board hitboxes remain live alongside the panel's actions. assert!(app .hitboxes .iter() @@ -384,7 +399,11 @@ mod tests { assert!(app .hitboxes .iter() - .any(|hitbox| hitbox.target == ClickTarget::Save)); + .any(|hitbox| hitbox.target == ClickTarget::EditIssue)); + assert!(app + .hitboxes + .iter() + .any(|hitbox| hitbox.target == ClickTarget::MoveIssueRight)); } #[test] @@ -395,10 +414,28 @@ mod tests { let screen = render_to_string(80, 36, &mut app); assert!(screen.contains("Issue #101")); + assert!(app.hitboxes.iter().all(|hitbox| { + matches!( + hitbox.target, + ClickTarget::EditIssue | ClickTarget::MoveIssueRight | ClickTarget::Cancel + ) + })); + } + + #[test] + fn edit_issue_opens_editor_modal_from_details() { + let mut app = App::demo(); + app.open_edit_issue(); + + let screen = render_to_string(120, 36, &mut app); + + assert!(screen.contains("Issue #101")); + assert!(screen.contains("Title")); + assert!(screen.contains("Save")); assert!(app .hitboxes .iter() - .all(|hitbox| matches!(hitbox.target, ClickTarget::Save | ClickTarget::Cancel))); + .any(|hitbox| hitbox.target == ClickTarget::Save)); } #[test] @@ -612,10 +649,10 @@ mod tests { let screen = render_to_string(120, 36, &mut app); assert!(screen.contains("Issue #101")); - assert!(screen.contains("Title")); + assert!(screen.contains("Draft user story")); assert!(screen.contains("Description")); - assert!(screen.contains("Save")); - assert!(screen.contains("Cancel")); + assert!(screen.contains("Labels")); + assert!(screen.contains("Edit")); for stale in [ "ORDER", "LOCKED TICKET", @@ -729,7 +766,7 @@ mod tests { .iter() .any(|hitbox| hitbox.target == ClickTarget::NewIssue)); - app.open_details(); + app.open_edit_issue(); terminal .draw(|frame| render(frame, &mut app)) .expect("modal render"); @@ -766,7 +803,7 @@ mod tests { assert!(screen.contains("IN PROGRESS")); assert!(screen.contains("DONE")); assert!(!screen.contains("REVIEW")); - assert!(screen.contains("┌")); + assert!(screen.contains("╭"), "rounded corners expected"); assert!(screen.contains("─")); assert!(screen.contains("│")); } @@ -893,7 +930,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(120, 36); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_edit_issue(); terminal .draw(|frame| render(frame, &mut app)) @@ -987,7 +1024,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(100, 32); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_edit_issue(); terminal .draw(|frame| render(frame, &mut app)) @@ -1042,7 +1079,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(100, 32); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_edit_issue(); terminal .draw(|frame| render(frame, &mut app)) .expect("modal render"); @@ -1056,7 +1093,7 @@ mod tests { let y = save.y; app.mouse_down_at(x, y); - assert_eq!(app.overlay, Some(Overlay::Details)); + assert_eq!(app.overlay, Some(Overlay::EditIssue)); app.mouse_up_at(x, y); assert_eq!(app.overlay, None); @@ -1130,7 +1167,7 @@ mod tests { let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_edit_issue(); app.focus_next_editor_field(); app.clear_editor(); for ch in "Editable description".chars() { @@ -1156,7 +1193,7 @@ mod tests { let backend = ratatui::backend::TestBackend::new(120, 36); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); let mut app = App::demo(); - app.open_details(); + app.open_edit_issue(); app.focus_next_editor_field(); app.clear_editor(); for ch in "# Heading\n- [x] Done\n`code` and **bold**".chars() { diff --git a/src/ui/modals.rs b/src/ui/modals.rs index 17921ff..7335dd0 100644 --- a/src/ui/modals.rs +++ b/src/ui/modals.rs @@ -66,19 +66,16 @@ fn render_editor_scrollbar(frame: &mut Frame, body: Rect, total_lines: usize, po ); } -/// Issue details as a right-hand side panel (wide terminals). Shares the -/// editor content and Save/Cancel semantics with the centered modal; only -/// the framing differs. +/// 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. pub fn render_details_panel(frame: &mut Frame, area: Rect, app: &mut App) { - let Some(issue) = app.selected_issue_opt() else { + let Some(issue) = app.selected_issue_opt().cloned() else { return; }; - let iid = issue.iid; - let state = issue.state; - let accent = super::board::state_color(state); let block = Block::new() - .title(format!(" Issue #{iid} ")) + .title(format!(" Issue #{} ", issue.iid)) .borders(Borders::ALL) .border_type(BorderType::Plain) .border_set(theme::solid_border()) @@ -86,28 +83,17 @@ pub fn render_details_panel(frame: &mut Frame, area: Rect, app: &mut App) { .style(Style::default().bg(theme::SURFACE)); let inner = block.inner(area); frame.render_widget(block, area); - if inner.height < 4 || inner.width < 10 { + if inner.height < 6 || inner.width < 12 { return; } - frame.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - format!(" {} ", state.title()), - Style::default() - .fg(Color::Black) - .bg(accent) - .add_modifier(Modifier::BOLD), - )])), - Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1), - ); - let body = Rect::new( inner.x + 1, - inner.y + 2, + inner.y, inner.width.saturating_sub(2), - inner.height.saturating_sub(4), + inner.height.saturating_sub(2), ); - let lines = issue_editor_lines(app); + let lines = issue_view_lines(app, &issue); let total_lines = lines.len(); frame.render_widget( Paragraph::new(Text::from(lines)) @@ -120,13 +106,160 @@ pub fn render_details_panel(frame: &mut Frame, area: Rect, app: &mut App) { ); render_editor_scrollbar(frame, body, total_lines, app.editor_description_scroll); - let footer_y = inner.y + inner.height.saturating_sub(1); - let save = Rect::new(inner.x + 1, footer_y, 10, 1); - let cancel = Rect::new(inner.x + 13, footer_y, 8, 1); - render_button(frame, save, "Save", theme::button_primary_style()); - render_button(frame, cancel, "Cancel", theme::button_secondary_style()); - app.add_hitbox(save, ClickTarget::Save); - app.add_hitbox(cancel, ClickTarget::Cancel); + render_details_actions( + frame, + app, + Rect::new( + inner.x + 1, + inner.y + inner.height.saturating_sub(1), + inner.width.saturating_sub(2), + 1, + ), + ); +} + +/// The same read-only issue view as a centered modal (narrow terminals). +pub fn render_details_view_modal(frame: &mut Frame, area: Rect, app: &mut App) { + let Some(issue) = app.selected_issue_opt().cloned() else { + return; + }; + + let chrome = modal_frame(frame, area, 62, 20, &format!("Issue #{}", issue.iid)); + + let lines = issue_view_lines(app, &issue); + let total_lines = lines.len(); + frame.render_widget( + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll(( + app.editor_description_scroll.min(u16::MAX as usize) as u16, + 0, + )), + 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)), + Rect::new( + chrome.rect.x, + chrome.footer.y.saturating_sub(1), + chrome.rect.width, + 2, + ), + ); + render_details_actions(frame, app, chrome.footer); +} + +/// 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()); + app.add_hitbox(edit, ClickTarget::EditIssue); + 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> { + let accent = super::board::state_color(issue.state); + let mut lines = vec![ + Line::from(vec![ + Span::styled("● ", Style::default().fg(accent)), + Span::styled( + format!("#{} ", issue.iid), + Style::default().fg(accent).add_modifier(Modifier::BOLD), + ), + Span::styled( + issue.title.clone(), + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled( + format!(" {} ", issue.state.title()), + Style::default() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + if issue.blocked { + Span::styled(" BLOCKED ", theme::blocked_style()) + } else { + Span::raw("") + }, + ]), + Line::raw(""), + ]; + + let mut meta = vec![Span::styled( + "Labels ", + Style::default().fg(theme::TEXT_MUTED), + )]; + if issue.labels.is_empty() { + meta.push(Span::styled( + "(none)", + Style::default().fg(theme::TEXT_MUTED), + )); + } else { + for (index, label) in issue.labels.iter().enumerate() { + if index > 0 { + meta.push(Span::raw(" ")); + } + meta.push(Span::styled( + format!(" {label} "), + theme::chip_style(super::board::label_color(label)), + )); + } + } + 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)), + ])); + } + if let Some(source) = issue.source { + lines.push(Line::from(vec![ + Span::styled("Linked ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled( + format!("! {}#{}", source.project_id, source.issue_iid), + Style::default().fg(theme::ACCENT_SECONDARY), + ), + ])); + } + + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "Description", + Style::default() + .fg(theme::TEXT_MUTED) + .add_modifier(Modifier::BOLD), + ))); + 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), + ))); + + lines } /// The shared field stack for the issue editor: header, title field, diff --git a/src/ui/settings_ui.rs b/src/ui/settings_ui.rs index dfe6092..9aff83a 100644 --- a/src/ui/settings_ui.rs +++ b/src/ui/settings_ui.rs @@ -1,9 +1,14 @@ -//! Settings modal: tabbed sections over the persisted `Settings` draft. +//! Settings modal: ratatui `Tabs` over the persisted `Settings` draft, with +//! each text field rendered as a bordered input `Block` and toggles as +//! checkbox rows. -use ratatui::{prelude::*, widgets::Paragraph}; +use ratatui::{ + prelude::*, + widgets::{Block, BorderType, Borders, Paragraph, Tabs}, +}; use crate::settings::SettingsForm; -use crate::theme::{self, editor_value_style}; +use crate::theme; use crate::{App, ClickTarget}; use super::{modal_frame, render_button, ModalChrome}; @@ -15,16 +20,13 @@ pub fn render_settings_modal(frame: &mut Frame, area: Rect, app: &mut App) { let ModalChrome { rect, body, footer } = modal_frame(frame, area, 70, 24, "Settings"); - let mut constraints = vec![Constraint::Length(1), Constraint::Length(1)]; - let mut field_has_error = Vec::with_capacity(form.field_count()); + let mut constraints = vec![Constraint::Length(1)]; for field in 0..form.field_count() { - let has_error = form.error_for(form.section, field).is_some(); - constraints.push(Constraint::Length(1)); - constraints.push(Constraint::Length(1)); - if has_error { + if form.field_is_toggle(field) { constraints.push(Constraint::Length(1)); + } else { + constraints.push(Constraint::Length(3)); } - field_has_error.push(has_error); } constraints.push(Constraint::Min(0)); constraints.push(Constraint::Length(1)); @@ -35,41 +37,28 @@ pub fn render_settings_modal(frame: &mut Frame, area: Rect, app: &mut App) { .constraints(constraints) .split(body); - let tab_row = rows[0]; - render_tab_row(frame, tab_row, &form, app); - - let mut cursor = 2; - for (field, has_error) in field_has_error.iter().enumerate() { - let label_row = rows[cursor]; - let value_row = rows[cursor + 1]; - cursor += 2; - render_field(frame, label_row, value_row, &form, field); - if *has_error { - let error_row = rows[cursor]; - cursor += 1; - if let Some(message) = form.error_for(form.section, field) { - frame.render_widget( - Paragraph::new(Span::styled( - format!(" ✗ {message}"), - Style::default().fg(theme::ERROR), - )), - error_row, - ); - } + render_tab_row(frame, rows[0], &form, 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); + } else { + render_input_box(frame, row, &form, field); } } let feedback_row = rows[rows.len() - 2]; if let Some(feedback) = &form.feedback { let line = match feedback { - Ok(message) => Line::from(Span::styled( - format!("✓ {message}"), - Style::default().fg(theme::SUCCESS), - )), - Err(message) => Line::from(Span::styled( - format!("✗ {message}"), - Style::default().fg(theme::ERROR), - )), + Ok(message) => Line::from(vec![ + Span::styled("Status: ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(format!("✓ {message}"), Style::default().fg(theme::SUCCESS)), + ]), + Err(message) => Line::from(vec![ + Span::styled("Status: ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(format!("✗ {message}"), Style::default().fg(theme::ERROR)), + ]), }; frame.render_widget(Paragraph::new(line), feedback_row); } @@ -83,8 +72,8 @@ pub fn render_settings_modal(frame: &mut Frame, area: Rect, app: &mut App) { hint_row, ); - let save = Rect::new(rect.x + 2, footer.y, 13, 1); - let cancel = Rect::new(rect.x + 17, footer.y, 13, 1); + let save = Rect::new(rect.x + 2, footer.y, 15, 1); + let cancel = Rect::new(rect.x + 19, footer.y, 14, 1); render_button(frame, save, "Save (Ctrl+S)", theme::button_primary_style()); render_button( frame, @@ -97,88 +86,114 @@ pub fn render_settings_modal(frame: &mut Frame, area: Rect, app: &mut App) { } fn render_tab_row(frame: &mut Frame, area: Rect, form: &SettingsForm, app: &mut App) { - let mut spans = Vec::new(); - let mut x = area.x; - for (index, title) in crate::settings::SECTION_TITLES.iter().enumerate() { - if index > 0 { - spans.push(Span::raw(" ")); - x += 2; - } - let active = index == form.section; - let style = if active { + let tabs = Tabs::new(crate::settings::SECTION_TITLES.to_vec()) + .select(form.section) + .style(Style::default().fg(theme::TEXT_SECONDARY)) + .highlight_style( Style::default() .fg(Color::Black) .bg(theme::ACCENT) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme::TEXT_SECONDARY) - }; - let width = title.chars().count() as u16; + .add_modifier(Modifier::BOLD), + ) + .divider(Span::styled("│", Style::default().fg(theme::BORDER))); + frame.render_widget(tabs, area); + + // Tabs lays titles out as " title " separated by the divider; register a + // click hitbox matching each title cell. + let mut x = area.x; + for (index, title) in crate::settings::SECTION_TITLES.iter().enumerate() { + let width = title.chars().count() as u16 + 2; app.add_hitbox( - Rect::new(x, area.y, width + 2, 1), + Rect::new(x, area.y, width, 1), ClickTarget::SettingsSection { index }, ); - spans.push(Span::styled(*title, style)); - x += width; + x += width + 1; } - frame.render_widget(Paragraph::new(Line::from(spans)), area); } -fn render_field( - frame: &mut Frame, - label_row: Rect, - value_row: Rect, - form: &SettingsForm, - index: usize, -) { +/// 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) { let focused = form.focused == index; - let label_text = if focused { - format!("{} Active", form.field_label(index)) + let error = form.error_for(form.section, index); + let border_color = if error.is_some() { + theme::ERROR + } else if focused { + theme::BORDER_FOCUS } else { - form.field_label(index).to_string() + theme::BORDER }; - frame.render_widget( - Paragraph::new(Span::styled( - label_text, - Style::default().fg(theme::TEXT_MUTED), - )), - label_row, - ); + + let mut title_spans = vec![Span::styled( + format!(" {} ", form.field_label(index)), + Style::default().fg(if focused { + theme::TEXT_SECONDARY + } else { + theme::TEXT_MUTED + }), + )]; + if let Some(message) = error { + title_spans.push(Span::styled( + format!("✗ {message} "), + Style::default().fg(theme::ERROR), + )); + } + + let block = Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_set(theme::solid_border()) + .border_style(Style::default().fg(border_color)) + .title(Line::from(title_spans)); + let inner = block.inner(area); + frame.render_widget(block, area); let value = form.field_value(index); - let line = if form.field_is_toggle(index) { - let on = value == "on"; - let text = if on { " [x] on" } else { " [ ] off" }; - Line::from(Span::styled( - text, - Style::default().fg(if on { - theme::SUCCESS - } else { - theme::TEXT_MUTED - }), - )) - } else if form.field_is_secret(index) { + let display = if form.field_is_secret(index) { if value.is_empty() { - Line::from(Span::styled( - " (not set)", - Style::default().fg(theme::TEXT_MUTED), - )) + String::new() } else { - let masked = "\u{2022}".repeat(value.chars().count().min(24)); - let style = editor_value_style(focused); - let mut spans = vec![Span::styled(format!(" {masked}"), style)]; - if focused { - spans.push(Span::styled("\u{258c}", style)); - } - Line::from(spans) + "\u{2022}".repeat(value.chars().count().min(32)) } } else { - let style = editor_value_style(focused); - let mut spans = vec![Span::styled(format!(" {value}"), style)]; - if focused { - spans.push(Span::styled("\u{258c}", style)); - } - Line::from(spans) + value }; - frame.render_widget(Paragraph::new(line), value_row); + let cursor = if focused { "▌" } else { "" }; + let content = if display.is_empty() && !focused { + Span::styled("(not set)", Style::default().fg(theme::TEXT_MUTED)) + } else { + Span::styled( + format!("{display}{cursor}"), + Style::default().fg(theme::TEXT), + ) + }; + frame.render_widget(Paragraph::new(Line::from(content)), inner); +} + +fn render_toggle(frame: &mut Frame, area: Rect, form: &SettingsForm, index: usize) { + let focused = form.focused == index; + let on = form.field_value(index) == "on"; + let line = Line::from(vec![ + Span::styled( + if focused { "▸ " } else { " " }, + Style::default().fg(theme::ACCENT), + ), + Span::styled( + format!("{} ", if on { "☑" } else { "☐" }), + Style::default().fg(if on { + theme::SUCCESS + } else { + theme::TEXT_MUTED + }), + ), + Span::styled( + form.field_label(index), + Style::default().fg(if focused { + theme::TEXT + } else { + theme::TEXT_SECONDARY + }), + ), + ]); + frame.render_widget(Paragraph::new(line), area); } diff --git a/src/ui/stories.rs b/src/ui/stories.rs index 13153d3..17bf5af 100644 --- a/src/ui/stories.rs +++ b/src/ui/stories.rs @@ -3,7 +3,7 @@ use ratatui::{ prelude::*, - widgets::{Block, Paragraph, Wrap}, + widgets::{Block, Paragraph, Tabs, Wrap}, }; use crate::theme::{self, editor_value_style}; @@ -26,7 +26,26 @@ pub fn render_generate_stories_modal(frame: &mut Frame, area: Rect, app: &mut Ap .split(body); let (tab_row, content_area, status_row, hint_row) = (rows[0], rows[2], rows[3], rows[4]); - frame.render_widget(Paragraph::new(tab_bar_line(app, tab_row.width)), tab_row); + let active_tab = match app.story_tab() { + StoryTab::Edit => 0, + StoryTab::Preview => 1, + StoryTab::Generated => 2, + }; + let tabs = Tabs::new(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))); + frame.render_widget(tabs, tab_row); match app.story_tab() { StoryTab::Edit => render_edit_tab(frame, content_area, app), @@ -50,59 +69,26 @@ pub fn render_generate_stories_modal(frame: &mut Frame, area: Rect, app: &mut Ap strip, ); let create = Rect::new(rect.x + 2, footer.y, 17, 1); - let cancel = Rect::new(rect.x + 21, footer.y, 8, 1); + let select_all = Rect::new(rect.x + 21, footer.y, 12, 1); + let cancel = Rect::new(rect.x + 35, footer.y, 8, 1); render_button( frame, create, "Create Selected", theme::button_primary_style(), ); + render_button( + frame, + select_all, + "Select All", + theme::button_secondary_style(), + ); render_button(frame, cancel, "Cancel", theme::button_secondary_style()); app.add_hitbox(create, ClickTarget::CreateSelectedStories); + app.add_hitbox(select_all, ClickTarget::SelectAllStories); app.add_hitbox(cancel, ClickTarget::Cancel); } -fn tab_bar_line(app: &App, width: u16) -> Line<'static> { - let active = app.story_tab(); - let labels = [ - (StoryTab::Edit, "Edit".to_string()), - (StoryTab::Preview, "Preview".to_string()), - ( - StoryTab::Generated, - format!("Generated ({})", app.generated_stories.len()), - ), - ]; - - let mut spans = Vec::new(); - let mut used: u16 = 0; - for (index, (tab, label)) in labels.iter().enumerate() { - if index > 0 { - spans.push(Span::raw(" ")); - used += 2; - } - let style = if *tab == active { - Style::default() - .fg(Color::Black) - .bg(theme::ACCENT) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme::TEXT_SECONDARY) - }; - used += label.chars().count() as u16; - spans.push(Span::styled(label.clone(), style)); - } - - let hint = "Ctrl+P preview"; - let hint_len = hint.chars().count() as u16; - if width > used + hint_len + 2 { - let gap = width - used - hint_len; - spans.push(Span::raw(" ".repeat(gap as usize))); - spans.push(Span::styled(hint, Style::default().fg(theme::TEXT_MUTED))); - } - - Line::from(spans) -} - fn render_edit_tab(frame: &mut Frame, area: Rect, app: &App) { if area.height == 0 { return; @@ -243,7 +229,7 @@ fn render_generated_tab(frame: &mut Frame, area: Rect, app: &App) { } detail_spans.push(Span::styled( format!(" {label} "), - theme::chip_style(theme::ACCENT_SECONDARY), + theme::chip_style(super::board::label_color(label)), )); } } @@ -293,7 +279,7 @@ fn hint_for(app: &App) -> &'static str { "Ctrl+G generate. Ctrl+P preview. Tab list. Ctrl+S create selected. Esc close." } StoryModalFocus::List => { - "Up/Down select. Space toggle. Enter edit title. Tab source/edit. Ctrl+S create." + "Up/Down select. Space toggle. Ctrl+A all. Enter edit title. Ctrl+S create." } StoryModalFocus::Edit => { "Type title. Backspace delete. Enter done. Tab source. Ctrl+S create." From 55634b4b1077082de3c43b9f99638f81ee3b8f5d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 2 Jul 2026 18:13:45 +0000 Subject: [PATCH 4/4] fix: restore modal clicks, editable labels field, AI feature guard - Mouse capture stays enabled everywhere again so popup buttons are clickable; terminal-native text selection is available via Shift+drag (documented in Help) - Labels are now editable in the issue editor: Tab cycles Title -> Description -> Labels; comma-separated input parsed and deduplicated on save, synced with AI label suggestions - AI actions (improve/criteria/suggest labels/generate stories) are refused with a warning pointing to Settings -> AI when AI features are disabled; the editor hint reflects the disabled state; flag derives from config (env or settings) at startup and on settings reload 282 tests pass; fmt clean; clippy unchanged (pre-existing warnings only). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EJsFyUeN5xJiw9hQzjx3Gj --- src/jobs.rs | 1 + src/lib.rs | 141 ++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 28 ++-------- src/ui/help.rs | 3 +- src/ui/mod.rs | 2 +- src/ui/modals.rs | 37 +++++++------ 6 files changed, 169 insertions(+), 43 deletions(-) diff --git a/src/jobs.rs b/src/jobs.rs index 7730d27..0174ee7 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -986,6 +986,7 @@ pub fn apply_job_result(app: &mut crate::App, result: JobResult) { if app.ai_generation_id == generation_id { app.ai_job_in_flight = false; app.editor_labels = labels; + app.editor_labels_input = app.editor_labels.join(", "); app.status_kind = crate::StatusKind::Success; app.story_status = Some(format!( "Suggested labels: {}", diff --git a/src/lib.rs b/src/lib.rs index 13ca338..1a3573e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ pub const MAX_DESCRIPTION_CHARS: usize = 4_000; pub const MAX_STORY_SOURCE_CHARS: usize = 8_000; pub const MAX_NOTE_CHARS: usize = MAX_DESCRIPTION_CHARS; pub const MAX_SEARCH_CHARS: usize = 80; +pub const MAX_LABELS_CHARS: usize = 200; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LaneState { @@ -77,6 +78,7 @@ pub enum Overlay { pub enum EditorField { Title, Description, + Labels, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -193,6 +195,7 @@ pub struct App { pub editor_title: String, pub editor_description: String, pub editor_labels: Vec, + pub editor_labels_input: String, pub editor_field: EditorField, pub editor_preview: bool, pub editor_description_scroll: usize, @@ -218,6 +221,7 @@ pub struct App { pub config_dirty: bool, pub clipboard_request: Option, pub last_synced: Option, + pub ai_enabled: bool, pub help_scroll: usize, pub connected_user: Option, pub connected_project: Option, @@ -388,6 +392,7 @@ impl App { editor_title: String::new(), editor_description: String::new(), editor_labels: Vec::new(), + editor_labels_input: String::new(), editor_field: EditorField::Title, editor_preview: false, editor_description_scroll: 0, @@ -413,6 +418,7 @@ impl App { config_dirty: false, clipboard_request: None, last_synced: None, + ai_enabled: true, help_scroll: 0, connected_user: None, connected_project: None, @@ -582,6 +588,7 @@ impl App { self.editor_title = issue.title; self.editor_description = issue.description; self.editor_labels = pick_user_labels(&issue.labels); + self.editor_labels_input = self.editor_labels.join(", "); self.editor_field = EditorField::Title; self.editor_preview = false; self.editor_description_scroll = 0; @@ -695,6 +702,7 @@ impl App { self.editor_title.clear(); self.editor_description.clear(); self.editor_labels.clear(); + self.editor_labels_input.clear(); self.editor_field = EditorField::Title; self.editor_preview = false; self.editor_description_scroll = 0; @@ -830,6 +838,7 @@ impl App { let limit = match self.editor_field { EditorField::Title => MAX_TITLE_CHARS, EditorField::Description => MAX_DESCRIPTION_CHARS, + EditorField::Labels => MAX_LABELS_CHARS, }; push_bounded_char(self.focused_editor_mut(), ch, limit); } @@ -963,6 +972,9 @@ impl App { } pub fn request_story_generation(&mut self, available_labels: Vec) { + if !self.ai_guard() { + return; + } let generation_id = self.start_ai_request(); self.generated_stories.clear(); self.story_selected = 0; @@ -975,7 +987,23 @@ impl App { self.set_status(StatusKind::Info, "Generating stories"); } + /// Central guard: AI actions are refused while AI features are disabled + /// in Settings, with guidance on how to enable them. + fn ai_guard(&mut self) -> bool { + if self.ai_enabled { + return true; + } + self.set_status( + StatusKind::Warning, + "AI features are disabled — enable them in Settings (,) under AI", + ); + false + } + pub fn request_improve_description(&mut self) { + if !self.ai_guard() { + return; + } let Some((title, description)) = self.editor_request_payload() else { return; }; @@ -989,6 +1017,9 @@ impl App { } pub fn request_generate_criteria(&mut self) { + if !self.ai_guard() { + return; + } let Some((title, description)) = self.editor_request_payload() else { return; }; @@ -1002,6 +1033,9 @@ impl App { } pub fn request_suggest_labels(&mut self) { + if !self.ai_guard() { + return; + } let Some((title, description)) = self.editor_request_payload() else { return; }; @@ -1182,7 +1216,8 @@ impl App { self.editor_preview = false; self.editor_field = match self.editor_field { EditorField::Title => EditorField::Description, - EditorField::Description => EditorField::Title, + EditorField::Description => EditorField::Labels, + EditorField::Labels => EditorField::Title, }; } } @@ -1402,6 +1437,9 @@ impl App { EditorField::Description => { paste_multi_line(&mut self.editor_description, text, MAX_DESCRIPTION_CHARS) } + EditorField::Labels => { + paste_single_line(&mut self.editor_labels_input, text, MAX_LABELS_CHARS) + } } } Some(Overlay::Notes) => paste_multi_line(&mut self.note_input, text, MAX_NOTE_CHARS), @@ -1441,6 +1479,7 @@ impl App { Some(Overlay::EditIssue | Overlay::NewIssue) => match self.editor_field { EditorField::Title => self.editor_title.clone(), EditorField::Description => self.editor_description.clone(), + EditorField::Labels => self.editor_labels_input.clone(), }, Some(Overlay::Notes) => self.note_input.clone(), Some(Overlay::GenerateStories) => self.story_source.clone(), @@ -1480,6 +1519,9 @@ impl App { } pub fn save_overlay(&mut self) { + if matches!(self.overlay, Some(Overlay::EditIssue | Overlay::NewIssue)) { + self.editor_labels = self.parsed_editor_labels(); + } let title = truncate_chars(self.editor_title.trim(), MAX_TITLE_CHARS); let description = truncate_chars(self.editor_description.trim(), MAX_DESCRIPTION_CHARS); match self.overlay.clone() { @@ -1595,7 +1637,20 @@ impl App { match self.editor_field { EditorField::Title => &mut self.editor_title, EditorField::Description => &mut self.editor_description, + EditorField::Labels => &mut self.editor_labels_input, + } + } + + /// Parse the comma-separated labels input into a deduplicated list. + pub fn parsed_editor_labels(&self) -> Vec { + let mut labels: Vec = Vec::new(); + for label in self.editor_labels_input.split(',') { + let label = label.trim(); + if !label.is_empty() && !labels.iter().any(|known| known == label) { + labels.push(label.to_string()); + } } + labels } pub fn toggle_blocked(&mut self) { @@ -3447,4 +3502,88 @@ mod tests { .as_deref() .is_some_and(|status| status.contains("Copied"))); } + + #[test] + fn labels_field_edits_and_saves_parsed_labels() { + let mut app = App::demo(); + app.open_edit_issue(); + assert_eq!(app.editor_labels_input, "ux, story"); + + // Tab to Labels (Title -> Description -> Labels) and retype. + app.focus_next_editor_field(); + app.focus_next_editor_field(); + assert_eq!(app.editor_field, EditorField::Labels); + app.clear_editor(); + for ch in "backend, ui , backend,".chars() { + app.input_editor_char(ch); + } + app.save_overlay(); + + assert_eq!(app.overlay, None); + assert_eq!(app.selected_issue().labels, vec!["backend", "ui"]); + } + + #[test] + fn labels_field_queues_update_with_parsed_labels_when_connected() { + let mut app = App::demo(); + app.connected_project = Some(GitLabProject { + id: 42, + path_with_namespace: "demo/owner".into(), + web_url: "https://gitlab.example.com/demo/owner".into(), + }); + app.open_edit_issue(); + app.focus_next_editor_field(); + app.focus_next_editor_field(); + app.clear_editor(); + for ch in "frontend, a11y".chars() { + app.input_editor_char(ch); + } + app.save_overlay(); + + assert!(matches!( + app.pending_job, + Some(crate::jobs::JobCommand::UpdateIssue { ref labels, .. }) + if labels == &vec!["frontend".to_string(), "a11y".to_string()] + )); + } + + #[test] + fn ai_requests_are_blocked_when_ai_disabled() { + let mut app = App::demo(); + app.ai_enabled = false; + + app.open_edit_issue(); + app.request_improve_description(); + assert_eq!(app.pending_job, None); + assert!(!app.ai_job_in_flight); + assert_eq!(app.status_kind, StatusKind::Warning); + assert!(app + .story_status + .as_deref() + .is_some_and(|status| status.contains("AI features are disabled"))); + + app.request_generate_criteria(); + app.request_suggest_labels(); + assert_eq!(app.pending_job, None); + + app.close_overlay(); + app.open_generate_stories(); + app.request_story_generation(Vec::new()); + assert_eq!(app.pending_job, None); + assert!(!app.ai_job_in_flight); + } + + #[test] + fn ai_requests_flow_when_ai_enabled() { + let mut app = App::demo(); + assert!(app.ai_enabled); + app.open_edit_issue(); + + app.request_improve_description(); + + assert!(matches!( + app.pending_job, + Some(crate::jobs::JobCommand::ImproveDescription { .. }) + )); + } } diff --git a/src/main.rs b/src/main.rs index 583d7f6..a5c2d8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,12 +53,7 @@ fn main() -> io::Result<()> { let mut terminal = Terminal::new(backend)?; let mut app = App::demo(); - let mut mouse_captured = true; - let result = run(&mut terminal, &mut app, &mut mouse_captured); - if !mouse_captured { - // Re-enable so the teardown disable below leaves a consistent state. - let _ = execute!(terminal.backend_mut(), EnableMouseCapture); - } + let result = run(&mut terminal, &mut app); disable_raw_mode()?; if keyboard_enhanced { @@ -111,11 +106,7 @@ fn base64_encode(input: &[u8]) -> String { out } -fn run( - terminal: &mut Terminal>, - app: &mut App, - mouse_captured: &mut bool, -) -> io::Result<()> { +fn run(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { let mut config = load_config(); prepare_startup(&config, app); let mut job_rx: Option> = None; @@ -147,19 +138,6 @@ fn run( return Ok(()); } - // Board mode captures the mouse for drag & drop; inside overlays the - // capture is released so the terminal's native text selection and - // copy work in text areas. - let want_capture = app.overlay.is_none(); - if want_capture != *mouse_captured { - if want_capture { - execute!(terminal.backend_mut(), EnableMouseCapture)?; - } else { - execute!(terminal.backend_mut(), DisableMouseCapture)?; - } - *mouse_captured = want_capture; - } - if event::poll(Duration::from_millis(40))? { handle_event(event::read()?, app); } @@ -173,6 +151,7 @@ fn prepare_startup(config: &Result, app: &mut App) { app.settings = lane_ratatui_premium::settings::load(); 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, @@ -180,6 +159,7 @@ fn prepare_startup(config: &Result, app: &mut App) { app.request_connect(); } Err(err) => { + app.ai_enabled = app.settings.ai.enabled; app.set_status( StatusKind::Error, format!("Config error: {err}. Press Ctrl+, to open Settings."), diff --git a/src/ui/help.rs b/src/ui/help.rs index 3cb7d95..c4b3795 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -66,6 +66,7 @@ fn help_columns() -> (Vec>, Vec>) { ("c", "Connect"), ("s / r", "Sync"), ("Ctrl+C", "Copy field / issue to clipboard"), + ("Shift+Drag", "Select text (terminal native)"), ("q", "Quit"), ], ); @@ -87,7 +88,7 @@ fn help_columns() -> (Vec>, Vec>) { &mut right, "Modals", &[ - ("Tab", "Next field"), + ("Tab", "Cycle Title / Description / Labels"), ("Enter", "Confirm / newline"), ("Ctrl+S", "Save"), ("Esc", "Close / cancel"), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 51400cc..8994796 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1131,7 +1131,7 @@ mod tests { fn ai_editor_modal_renders_labels_and_shortcuts() { let mut app = App::demo(); app.open_new_issue(); - app.editor_labels = vec!["backend".into()]; + app.editor_labels_input = "backend".into(); let screen = render_to_string(120, 36, &mut app); diff --git a/src/ui/modals.rs b/src/ui/modals.rs index 7335dd0..3f04832 100644 --- a/src/ui/modals.rs +++ b/src/ui/modals.rs @@ -277,30 +277,25 @@ fn issue_editor_lines(app: &App) -> Vec> { } else { "Description" }; + let labels_active = app.editor_field == EditorField::Labels; let hint = if app.editor_preview { "Ctrl+P Edit. PgUp/PgDn Scroll. Ctrl+S Save." } else if description_active { "Enter newline. Up/Down or PgUp/PgDn scroll. Ctrl+P Preview. Ctrl+S Save." + } else if labels_active { + "Comma separates labels. Tab next field. Ctrl+S Save." } else { "Tab switches field. Enter saves." }; - let labels = if app.editor_labels.is_empty() { - "(none)".to_string() - } else { - app.editor_labels.join(", ") - }; - let ai_hint_text = if app.ai_job_in_flight { - "Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel AI." - } else { - "Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel." - }; - let ai_hint = if app.ai_job_in_flight { + let ai_hint = if !app.ai_enabled { + "AI disabled — enable in Settings (,) under AI.".to_string() + } else if app.ai_job_in_flight { format!( - "{} AI working… {ai_hint_text}", + "{} AI working… Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel AI.", theme::spinner_frame(app.loading_tick) ) } else { - ai_hint_text.to_string() + "Ctrl+A Improve. Ctrl+Y Criteria. Ctrl+L Labels. Esc Cancel.".to_string() }; let mut lines = vec![ @@ -339,12 +334,22 @@ fn issue_editor_lines(app: &App) -> Vec> { )); lines.push(Line::raw("")); lines.push(Line::from(vec![Span::styled( - "Labels", + if labels_active { + "Labels Active (comma separated)" + } else { + "Labels" + }, Style::default().fg(theme::TEXT_MUTED), )])); + let labels_cursor = if labels_active { "▌" } else { "" }; + let labels_display = if app.editor_labels_input.is_empty() && !labels_active { + "(none)".to_string() + } else { + app.editor_labels_input.clone() + }; lines.push(Line::from(vec![Span::styled( - format!(" {labels}"), - theme::editor_value_style(false), + format!(" {labels_display}{labels_cursor}"), + theme::editor_value_style(labels_active), )])); lines.push(Line::raw("")); lines.push(Line::from(vec![Span::styled(