be69e78c
Filter, search and create issues from the TUI dashboard
a73x 2026-03-21 08:59
Commit message
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -240,7 +240,7 @@ pub fn generate_diff(repo: &Repository, patch: &PatchState) -> Result<String, Er | |||
| 240 | let mut lines = 0usize; | 240 | let mut lines = 0usize; |
| 241 | git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| { | 241 | git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| { |
| 242 | if lines >= 5000 { | 242 | if lines >= 5000 { |
| 243 | return false; | 243 | return true; // stop appending but don't abort libgit2 |
| 244 | } | 244 | } |
| 245 | let prefix = match line.origin() { | 245 | let prefix = match line.origin() { |
| 246 | '+' => "+", | 246 | '+' => "+", |
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -25,6 +25,7 @@ pub struct IssueState { | |||
| 25 | pub body: String, | 25 | pub body: String, |
| 26 | pub status: IssueStatus, | 26 | pub status: IssueStatus, |
| 27 | pub close_reason: Option<String>, | 27 | pub close_reason: Option<String>, |
| 28 | pub closed_by: Option<Oid>, | ||
| 28 | pub labels: Vec<String>, | 29 | pub labels: Vec<String>, |
| 29 | pub assignees: Vec<String>, | 30 | pub assignees: Vec<String>, |
| 30 | pub comments: Vec<Comment>, | 31 | pub comments: Vec<Comment>, |
| @@ -96,6 +97,7 @@ impl IssueState { | |||
| 96 | body, | 97 | body, |
| 97 | status: IssueStatus::Open, | 98 | status: IssueStatus::Open, |
| 98 | close_reason: None, | 99 | close_reason: None, |
| 100 | closed_by: None, | ||
| 99 | labels: Vec::new(), | 101 | labels: Vec::new(), |
| 100 | assignees: Vec::new(), | 102 | assignees: Vec::new(), |
| 101 | comments: Vec::new(), | 103 | comments: Vec::new(), |
| @@ -118,6 +120,7 @@ impl IssueState { | |||
| 118 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { | 120 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { |
| 119 | s.status = IssueStatus::Closed; | 121 | s.status = IssueStatus::Closed; |
| 120 | s.close_reason = reason; | 122 | s.close_reason = reason; |
| 123 | s.closed_by = Some(oid); | ||
| 121 | status_ts = Some(event.timestamp.clone()); | 124 | status_ts = Some(event.timestamp.clone()); |
| 122 | } | 125 | } |
| 123 | } | 126 | } |
| @@ -160,6 +163,8 @@ impl IssueState { | |||
| 160 | if let Some(ref mut s) = state { | 163 | if let Some(ref mut s) = state { |
| 161 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { | 164 | if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { |
| 162 | s.status = IssueStatus::Open; | 165 | s.status = IssueStatus::Open; |
| 166 | s.close_reason = None; | ||
| 167 | s.closed_by = None; | ||
| 163 | status_ts = Some(event.timestamp.clone()); | 168 | status_ts = Some(event.timestamp.clone()); |
| 164 | } | 169 | } |
| 165 | } | 170 | } |
src/tui.rs
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,7 @@ use ratatui::prelude::*; | |||
| 10 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; | 10 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; |
| 11 | 11 | ||
| 12 | use crate::error::Error; | 12 | use crate::error::Error; |
| 13 | use crate::issue as issue_mod; | ||
| 13 | use crate::patch as patch_mod; | 14 | use crate::patch as patch_mod; |
| 14 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 15 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 15 | 16 | ||
| @@ -31,6 +32,39 @@ enum ViewMode { | |||
| 31 | Diff, | 32 | Diff, |
| 32 | } | 33 | } |
| 33 | 34 | ||
| 35 | #[derive(Debug, PartialEq, Clone, Copy)] | ||
| 36 | enum StatusFilter { | ||
| 37 | Open, | ||
| 38 | Closed, | ||
| 39 | All, | ||
| 40 | } | ||
| 41 | |||
| 42 | impl StatusFilter { | ||
| 43 | fn next(self) -> Self { | ||
| 44 | match self { | ||
| 45 | StatusFilter::Open => StatusFilter::All, | ||
| 46 | StatusFilter::All => StatusFilter::Closed, | ||
| 47 | StatusFilter::Closed => StatusFilter::Open, | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | fn label(self) -> &'static str { | ||
| 52 | match self { | ||
| 53 | StatusFilter::Open => "open", | ||
| 54 | StatusFilter::Closed => "closed", | ||
| 55 | StatusFilter::All => "all", | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | #[derive(Debug, PartialEq)] | ||
| 61 | enum InputMode { | ||
| 62 | Normal, | ||
| 63 | Search, | ||
| 64 | CreateTitle, | ||
| 65 | CreateBody, | ||
| 66 | } | ||
| 67 | |||
| 34 | struct App { | 68 | struct App { |
| 35 | tab: Tab, | 69 | tab: Tab, |
| 36 | issues: Vec<IssueState>, | 70 | issues: Vec<IssueState>, |
| @@ -40,7 +74,12 @@ struct App { | |||
| 40 | scroll: u16, | 74 | scroll: u16, |
| 41 | pane: Pane, | 75 | pane: Pane, |
| 42 | mode: ViewMode, | 76 | mode: ViewMode, |
| 43 | show_all: bool, | 77 | status_filter: StatusFilter, |
| 78 | search_query: String, | ||
| 79 | input_mode: InputMode, | ||
| 80 | input_buf: String, | ||
| 81 | create_title: String, | ||
| 82 | status_msg: Option<String>, | ||
| 44 | } | 83 | } |
| 45 | 84 | ||
| 46 | impl App { | 85 | impl App { |
| @@ -58,46 +97,53 @@ impl App { | |||
| 58 | scroll: 0, | 97 | scroll: 0, |
| 59 | pane: Pane::ItemList, | 98 | pane: Pane::ItemList, |
| 60 | mode: ViewMode::Details, | 99 | mode: ViewMode::Details, |
| 61 | show_all: false, | 100 | status_filter: StatusFilter::Open, |
| 101 | search_query: String::new(), | ||
| 102 | input_mode: InputMode::Normal, | ||
| 103 | input_buf: String::new(), | ||
| 104 | create_title: String::new(), | ||
| 105 | status_msg: None, | ||
| 62 | } | 106 | } |
| 63 | } | 107 | } |
| 64 | 108 | ||
| 65 | fn visible_issue_count(&self) -> usize { | 109 | fn matches_search(&self, title: &str) -> bool { |
| 66 | if self.show_all { | 110 | if self.search_query.is_empty() { |
| 67 | self.issues.len() | 111 | return true; |
| 68 | } else { | ||
| 69 | self.issues | ||
| 70 | .iter() | ||
| 71 | .filter(|i| i.status == IssueStatus::Open) | ||
| 72 | .count() | ||
| 73 | } | 112 | } |
| 113 | title | ||
| 114 | .to_lowercase() | ||
| 115 | .contains(&self.search_query.to_lowercase()) | ||
| 74 | } | 116 | } |
| 75 | 117 | ||
| 76 | fn visible_issues(&self) -> Vec<&IssueState> { | 118 | fn visible_issues(&self) -> Vec<&IssueState> { |
| 77 | if self.show_all { | 119 | self.issues |
| 78 | self.issues.iter().collect() | 120 | .iter() |
| 79 | } else { | 121 | .filter(|i| match self.status_filter { |
| 80 | self.issues | 122 | StatusFilter::Open => i.status == IssueStatus::Open, |
| 81 | .iter() | 123 | StatusFilter::Closed => i.status == IssueStatus::Closed, |
| 82 | .filter(|i| i.status == IssueStatus::Open) | 124 | StatusFilter::All => true, |
| 83 | .collect() | 125 | }) |
| 84 | } | 126 | .filter(|i| self.matches_search(&i.title)) |
| 127 | .collect() | ||
| 85 | } | 128 | } |
| 86 | 129 | ||
| 87 | fn visible_patches(&self) -> Vec<&PatchState> { | 130 | fn visible_patches(&self) -> Vec<&PatchState> { |
| 88 | if self.show_all { | 131 | self.patches |
| 89 | self.patches.iter().collect() | 132 | .iter() |
| 90 | } else { | 133 | .filter(|p| match self.status_filter { |
| 91 | self.patches | 134 | StatusFilter::Open => p.status == PatchStatus::Open, |
| 92 | .iter() | 135 | StatusFilter::Closed => { |
| 93 | .filter(|p| p.status == PatchStatus::Open) | 136 | p.status == PatchStatus::Closed || p.status == PatchStatus::Merged |
| 94 | .collect() | 137 | } |
| 95 | } | 138 | StatusFilter::All => true, |
| 139 | }) | ||
| 140 | .filter(|p| self.matches_search(&p.title)) | ||
| 141 | .collect() | ||
| 96 | } | 142 | } |
| 97 | 143 | ||
| 98 | fn visible_count(&self) -> usize { | 144 | fn visible_count(&self) -> usize { |
| 99 | match self.tab { | 145 | match self.tab { |
| 100 | Tab::Issues => self.visible_issue_count(), | 146 | Tab::Issues => self.visible_issues().len(), |
| 101 | Tab::Patches => self.visible_patches().len(), | 147 | Tab::Patches => self.visible_patches().len(), |
| 102 | } | 148 | } |
| 103 | } | 149 | } |
| @@ -132,6 +178,76 @@ impl App { | |||
| 132 | self.pane = Pane::ItemList; | 178 | self.pane = Pane::ItemList; |
| 133 | } | 179 | } |
| 134 | 180 | ||
| 181 | fn follow_link(&mut self) -> bool { | ||
| 182 | match self.tab { | ||
| 183 | Tab::Issues => { | ||
| 184 | // From an issue, jump to the first patch that fixes it | ||
| 185 | let visible = self.visible_issues(); | ||
| 186 | if let Some(idx) = self.list_state.selected() { | ||
| 187 | if let Some(issue) = visible.get(idx) { | ||
| 188 | let issue_id = issue.id.clone(); | ||
| 189 | let target = self | ||
| 190 | .patches | ||
| 191 | .iter() | ||
| 192 | .enumerate() | ||
| 193 | .find(|(_, p)| p.fixes.as_deref() == Some(&issue_id)); | ||
| 194 | if let Some((patch_idx, _)) = target { | ||
| 195 | if self.status_filter != StatusFilter::All { | ||
| 196 | let visible_patches = self.visible_patches(); | ||
| 197 | if !visible_patches | ||
| 198 | .iter() | ||
| 199 | .any(|p| p.fixes.as_deref() == Some(&issue_id)) | ||
| 200 | { | ||
| 201 | self.status_filter = StatusFilter::All; | ||
| 202 | } | ||
| 203 | } | ||
| 204 | let visible_patches = self.visible_patches(); | ||
| 205 | if let Some(vi) = visible_patches | ||
| 206 | .iter() | ||
| 207 | .position(|p| p.id == self.patches[patch_idx].id) | ||
| 208 | { | ||
| 209 | self.tab = Tab::Patches; | ||
| 210 | self.list_state.select(Some(vi)); | ||
| 211 | self.scroll = 0; | ||
| 212 | self.mode = ViewMode::Details; | ||
| 213 | return true; | ||
| 214 | } | ||
| 215 | } | ||
| 216 | } | ||
| 217 | } | ||
| 218 | return false; | ||
| 219 | } | ||
| 220 | Tab::Patches => { | ||
| 221 | // From a patch, jump to the linked issue (fixes field) | ||
| 222 | let visible = self.visible_patches(); | ||
| 223 | if let Some(idx) = self.list_state.selected() { | ||
| 224 | if let Some(patch) = visible.get(idx) { | ||
| 225 | if let Some(ref fixes_id) = patch.fixes { | ||
| 226 | let fixes_id = fixes_id.clone(); | ||
| 227 | if self.status_filter != StatusFilter::All { | ||
| 228 | let visible_issues = self.visible_issues(); | ||
| 229 | if !visible_issues.iter().any(|i| i.id == fixes_id) { | ||
| 230 | self.status_filter = StatusFilter::All; | ||
| 231 | } | ||
| 232 | } | ||
| 233 | let visible_issues = self.visible_issues(); | ||
| 234 | if let Some(vi) = | ||
| 235 | visible_issues.iter().position(|i| i.id == fixes_id) | ||
| 236 | { | ||
| 237 | self.tab = Tab::Issues; | ||
| 238 | self.list_state.select(Some(vi)); | ||
| 239 | self.scroll = 0; | ||
| 240 | self.mode = ViewMode::Details; | ||
| 241 | return true; | ||
| 242 | } | ||
| 243 | } | ||
| 244 | } | ||
| 245 | } | ||
| 246 | return false; | ||
| 247 | } | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 135 | fn reload(&mut self, repo: &Repository) { | 251 | fn reload(&mut self, repo: &Repository) { |
| 136 | if let Ok(issues) = state::list_issues(repo) { | 252 | if let Ok(issues) = state::list_issues(repo) { |
| 137 | self.issues = issues; | 253 | self.issues = issues; |
| @@ -200,11 +316,130 @@ fn run_loop( | |||
| 200 | 316 | ||
| 201 | if event::poll(Duration::from_millis(100))? { | 317 | if event::poll(Duration::from_millis(100))? { |
| 202 | if let Event::Key(key) = event::read()? { | 318 | if let Event::Key(key) = event::read()? { |
| 319 | app.status_msg = None; // clear status on any keypress | ||
| 320 | |||
| 321 | // Input mode intercept — handle keys before normal bindings | ||
| 322 | match app.input_mode { | ||
| 323 | InputMode::Search => { | ||
| 324 | match key.code { | ||
| 325 | KeyCode::Esc => { | ||
| 326 | app.input_mode = InputMode::Normal; | ||
| 327 | app.search_query.clear(); | ||
| 328 | let count = app.visible_count(); | ||
| 329 | app.list_state | ||
| 330 | .select(if count > 0 { Some(0) } else { None }); | ||
| 331 | } | ||
| 332 | KeyCode::Backspace => { | ||
| 333 | app.search_query.pop(); | ||
| 334 | let count = app.visible_count(); | ||
| 335 | app.list_state | ||
| 336 | .select(if count > 0 { Some(0) } else { None }); | ||
| 337 | } | ||
| 338 | KeyCode::Char(c) => { | ||
| 339 | app.search_query.push(c); | ||
| 340 | let count = app.visible_count(); | ||
| 341 | app.list_state | ||
| 342 | .select(if count > 0 { Some(0) } else { None }); | ||
| 343 | } | ||
| 344 | _ => {} | ||
| 345 | } | ||
| 346 | continue; | ||
| 347 | } | ||
| 348 | InputMode::CreateTitle => { | ||
| 349 | match key.code { | ||
| 350 | KeyCode::Esc => { | ||
| 351 | app.input_mode = InputMode::Normal; | ||
| 352 | app.input_buf.clear(); | ||
| 353 | } | ||
| 354 | KeyCode::Enter => { | ||
| 355 | let title = app.input_buf.trim().to_string(); | ||
| 356 | if title.is_empty() { | ||
| 357 | app.input_mode = InputMode::Normal; | ||
| 358 | app.input_buf.clear(); | ||
| 359 | } else { | ||
| 360 | app.create_title = title; | ||
| 361 | app.input_buf.clear(); | ||
| 362 | app.input_mode = InputMode::CreateBody; | ||
| 363 | } | ||
| 364 | } | ||
| 365 | KeyCode::Backspace => { | ||
| 366 | app.input_buf.pop(); | ||
| 367 | } | ||
| 368 | KeyCode::Char(c) => { | ||
| 369 | app.input_buf.push(c); | ||
| 370 | } | ||
| 371 | _ => {} | ||
| 372 | } | ||
| 373 | continue; | ||
| 374 | } | ||
| 375 | InputMode::CreateBody => { | ||
| 376 | match key.code { | ||
| 377 | KeyCode::Esc => { | ||
| 378 | // Submit with title only, no body | ||
| 379 | let title = app.create_title.clone(); | ||
| 380 | match issue_mod::open(repo, &title, "") { | ||
| 381 | Ok(id) => { | ||
| 382 | app.reload(repo); | ||
| 383 | app.status_msg = | ||
| 384 | Some(format!("Issue created: {:.8}", id)); | ||
| 385 | } | ||
| 386 | Err(e) => { | ||
| 387 | app.status_msg = | ||
| 388 | Some(format!("Error creating issue: {}", e)); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | app.input_mode = InputMode::Normal; | ||
| 392 | app.input_buf.clear(); | ||
| 393 | app.create_title.clear(); | ||
| 394 | } | ||
| 395 | KeyCode::Enter => { | ||
| 396 | let title = app.create_title.clone(); | ||
| 397 | let body = app.input_buf.clone(); | ||
| 398 | match issue_mod::open(repo, &title, &body) { | ||
| 399 | Ok(id) => { | ||
| 400 | app.reload(repo); | ||
| 401 | app.status_msg = | ||
| 402 | Some(format!("Issue created: {:.8}", id)); | ||
| 403 | } | ||
| 404 | Err(e) => { | ||
| 405 | app.status_msg = | ||
| 406 | Some(format!("Error creating issue: {}", e)); | ||
| 407 | } | ||
| 408 | } | ||
| 409 | app.input_mode = InputMode::Normal; | ||
| 410 | app.input_buf.clear(); | ||
| 411 | app.create_title.clear(); | ||
| 412 | } | ||
| 413 | KeyCode::Backspace => { | ||
| 414 | app.input_buf.pop(); | ||
| 415 | } | ||
| 416 | KeyCode::Char(c) => { | ||
| 417 | app.input_buf.push(c); | ||
| 418 | } | ||
| 419 | _ => {} | ||
| 420 | } | ||
| 421 | continue; | ||
| 422 | } | ||
| 423 | InputMode::Normal => {} | ||
| 424 | } | ||
| 425 | |||
| 203 | match key.code { | 426 | match key.code { |
| 204 | KeyCode::Char('q') | KeyCode::Esc => return Ok(()), | 427 | KeyCode::Char('q') | KeyCode::Esc => return Ok(()), |
| 205 | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { | 428 | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 206 | return Ok(()) | 429 | return Ok(()) |
| 207 | } | 430 | } |
| 431 | KeyCode::Char('/') => { | ||
| 432 | app.input_mode = InputMode::Search; | ||
| 433 | app.search_query.clear(); | ||
| 434 | } | ||
| 435 | KeyCode::Char('n') => { | ||
| 436 | if app.tab != Tab::Issues { | ||
| 437 | app.switch_tab(Tab::Issues); | ||
| 438 | } | ||
| 439 | app.input_mode = InputMode::CreateTitle; | ||
| 440 | app.input_buf.clear(); | ||
| 441 | app.create_title.clear(); | ||
| 442 | } | ||
| 208 | KeyCode::Char('1') => app.switch_tab(Tab::Issues), | 443 | KeyCode::Char('1') => app.switch_tab(Tab::Issues), |
| 209 | KeyCode::Char('2') => app.switch_tab(Tab::Patches), | 444 | KeyCode::Char('2') => app.switch_tab(Tab::Patches), |
| 210 | KeyCode::Char('j') | KeyCode::Down => { | 445 | KeyCode::Char('j') | KeyCode::Down => { |
| @@ -239,11 +474,68 @@ fn run_loop( | |||
| 239 | } | 474 | } |
| 240 | } | 475 | } |
| 241 | KeyCode::Char('a') => { | 476 | KeyCode::Char('a') => { |
| 242 | app.show_all = !app.show_all; | 477 | app.status_filter = app.status_filter.next(); |
| 243 | let count = app.visible_count(); | 478 | let count = app.visible_count(); |
| 244 | app.list_state | 479 | app.list_state |
| 245 | .select(if count > 0 { Some(0) } else { None }); | 480 | .select(if count > 0 { Some(0) } else { None }); |
| 246 | } | 481 | } |
| 482 | KeyCode::Char('g') => { | ||
| 483 | if !app.follow_link() { | ||
| 484 | app.status_msg = Some("No linked item to follow".to_string()); | ||
| 485 | } | ||
| 486 | } | ||
| 487 | KeyCode::Char('o') => { | ||
| 488 | // Check out the relevant commit for local browsing | ||
| 489 | let checkout_target = match app.tab { | ||
| 490 | Tab::Patches => { | ||
| 491 | let visible = app.visible_patches(); | ||
| 492 | app.list_state | ||
| 493 | .selected() | ||
| 494 | .and_then(|idx| visible.get(idx)) | ||
| 495 | .map(|p| p.head_commit.clone()) | ||
| 496 | } | ||
| 497 | Tab::Issues => { | ||
| 498 | // Find linked patch's head commit, or fall back to closing commit | ||
| 499 | let visible = app.visible_issues(); | ||
| 500 | app.list_state | ||
| 501 | .selected() | ||
| 502 | .and_then(|idx| visible.get(idx)) | ||
| 503 | .and_then(|issue| { | ||
| 504 | // Try linked patch first | ||
| 505 | app.patches | ||
| 506 | .iter() | ||
| 507 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 508 | .map(|p| p.head_commit.clone()) | ||
| 509 | // Fall back to closing commit | ||
| 510 | .or_else(|| issue.closed_by.map(|oid| oid.to_string())) | ||
| 511 | }) | ||
| 512 | } | ||
| 513 | }; | ||
| 514 | if let Some(head) = checkout_target { | ||
| 515 | // Exit TUI, checkout, and return | ||
| 516 | terminal::disable_raw_mode()?; | ||
| 517 | stdout().execute(LeaveAlternateScreen)?; | ||
| 518 | let status = std::process::Command::new("git") | ||
| 519 | .args(["checkout", &head]) | ||
| 520 | .status(); | ||
| 521 | match status { | ||
| 522 | Ok(s) if s.success() => { | ||
| 523 | println!("Checked out commit: {:.8}", head); | ||
| 524 | println!("Use 'git checkout -' to return."); | ||
| 525 | } | ||
| 526 | Ok(s) => { | ||
| 527 | eprintln!("git checkout exited with {}", s); | ||
| 528 | } | ||
| 529 | Err(e) => { | ||
| 530 | eprintln!("Failed to run git checkout: {}", e); | ||
| 531 | } | ||
| 532 | } | ||
| 533 | return Ok(()); | ||
| 534 | } else { | ||
| 535 | app.status_msg = | ||
| 536 | Some("No linked patch to check out".to_string()); | ||
| 537 | } | ||
| 538 | } | ||
| 247 | KeyCode::Char('r') => { | 539 | KeyCode::Char('r') => { |
| 248 | app.reload(repo); | 540 | app.reload(repo); |
| 249 | } | 541 | } |
| @@ -319,11 +611,7 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 319 | }) | 611 | }) |
| 320 | .collect(); | 612 | .collect(); |
| 321 | 613 | ||
| 322 | let title = if app.show_all { | 614 | let title = format!("Issues ({})", app.status_filter.label()); |
| 323 | "Issues (all)" | ||
| 324 | } else { | ||
| 325 | "Issues (open)" | ||
| 326 | }; | ||
| 327 | 615 | ||
| 328 | let list = List::new(items) | 616 | let list = List::new(items) |
| 329 | .block( | 617 | .block( |
| @@ -360,11 +648,7 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 360 | }) | 648 | }) |
| 361 | .collect(); | 649 | .collect(); |
| 362 | 650 | ||
| 363 | let title = if app.show_all { | 651 | let title = format!("Patches ({})", app.status_filter.label()); |
| 364 | "Patches (all)" | ||
| 365 | } else { | ||
| 366 | "Patches (open)" | ||
| 367 | }; | ||
| 368 | 652 | ||
| 369 | let list = List::new(items) | 653 | let list = List::new(items) |
| 370 | .block( | 654 | .block( |
| @@ -403,8 +687,8 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { | |||
| 403 | let visible = app.visible_issues(); | 687 | let visible = app.visible_issues(); |
| 404 | let selected_idx = app.list_state.selected().unwrap_or(0); | 688 | let selected_idx = app.list_state.selected().unwrap_or(0); |
| 405 | match visible.get(selected_idx) { | 689 | match visible.get(selected_idx) { |
| 406 | Some(issue) => build_issue_detail(issue), | 690 | Some(issue) => build_issue_detail(issue, &app.patches), |
| 407 | None => Text::raw("No issues to display."), | 691 | None => Text::raw("No matches for current filter."), |
| 408 | } | 692 | } |
| 409 | } | 693 | } |
| 410 | Tab::Patches => { | 694 | Tab::Patches => { |
| @@ -422,7 +706,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { | |||
| 422 | colorize_diff(diff_text, &patch.inline_comments) | 706 | colorize_diff(diff_text, &patch.inline_comments) |
| 423 | } | 707 | } |
| 424 | }, | 708 | }, |
| 425 | None => Text::raw("No patches to display."), | 709 | None => Text::raw("No matches for current filter."), |
| 426 | } | 710 | } |
| 427 | } | 711 | } |
| 428 | }; | 712 | }; |
| @@ -440,7 +724,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { | |||
| 440 | frame.render_widget(para, area); | 724 | frame.render_widget(para, area); |
| 441 | } | 725 | } |
| 442 | 726 | ||
| 443 | fn build_issue_detail(issue: &IssueState) -> Text<'static> { | 727 | fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> { |
| 444 | let status = match issue.status { | 728 | let status = match issue.status { |
| 445 | IssueStatus::Open => "open", | 729 | IssueStatus::Open => "open", |
| 446 | IssueStatus::Closed => "closed", | 730 | IssueStatus::Closed => "closed", |
| @@ -471,6 +755,20 @@ fn build_issue_detail(issue: &IssueState) -> Text<'static> { | |||
| 471 | ]), | 755 | ]), |
| 472 | ]; | 756 | ]; |
| 473 | 757 | ||
| 758 | if !issue.labels.is_empty() { | ||
| 759 | lines.push(Line::from(vec![ | ||
| 760 | Span::styled("Labels: ", Style::default().fg(Color::DarkGray)), | ||
| 761 | Span::raw(issue.labels.join(", ")), | ||
| 762 | ])); | ||
| 763 | } | ||
| 764 | |||
| 765 | if !issue.assignees.is_empty() { | ||
| 766 | lines.push(Line::from(vec![ | ||
| 767 | Span::styled("Assign: ", Style::default().fg(Color::DarkGray)), | ||
| 768 | Span::raw(issue.assignees.join(", ")), | ||
| 769 | ])); | ||
| 770 | } | ||
| 771 | |||
| 474 | if let Some(ref reason) = issue.close_reason { | 772 | if let Some(ref reason) = issue.close_reason { |
| 475 | lines.push(Line::from(vec![ | 773 | lines.push(Line::from(vec![ |
| 476 | Span::styled("Closed: ", Style::default().fg(Color::Red)), | 774 | Span::styled("Closed: ", Style::default().fg(Color::Red)), |
| @@ -478,6 +776,47 @@ fn build_issue_detail(issue: &IssueState) -> Text<'static> { | |||
| 478 | ])); | 776 | ])); |
| 479 | } | 777 | } |
| 480 | 778 | ||
| 779 | if let Some(ref oid) = issue.closed_by { | ||
| 780 | lines.push(Line::from(vec![ | ||
| 781 | Span::styled("Commit: ", Style::default().fg(Color::DarkGray)), | ||
| 782 | Span::styled( | ||
| 783 | format!("{:.8}", oid), | ||
| 784 | Style::default().fg(Color::Cyan), | ||
| 785 | ), | ||
| 786 | ])); | ||
| 787 | } | ||
| 788 | |||
| 789 | // Show patches that reference this issue via --fixes | ||
| 790 | let fixing_patches: Vec<&PatchState> = patches | ||
| 791 | .iter() | ||
| 792 | .filter(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 793 | .collect(); | ||
| 794 | if !fixing_patches.is_empty() { | ||
| 795 | lines.push(Line::raw("")); | ||
| 796 | lines.push(Line::styled( | ||
| 797 | "--- Linked Patches ---", | ||
| 798 | Style::default() | ||
| 799 | .fg(Color::Magenta) | ||
| 800 | .add_modifier(Modifier::BOLD), | ||
| 801 | )); | ||
| 802 | for p in &fixing_patches { | ||
| 803 | let status = match p.status { | ||
| 804 | PatchStatus::Open => ("open", Color::Green), | ||
| 805 | PatchStatus::Closed => ("closed", Color::Red), | ||
| 806 | PatchStatus::Merged => ("merged", Color::Cyan), | ||
| 807 | }; | ||
| 808 | lines.push(Line::from(vec![ | ||
| 809 | Span::styled( | ||
| 810 | format!("{:.8}", p.id), | ||
| 811 | Style::default().fg(Color::Yellow), | ||
| 812 | ), | ||
| 813 | Span::raw(" "), | ||
| 814 | Span::styled(status.0, Style::default().fg(status.1)), | ||
| 815 | Span::raw(format!(" {}", p.title)), | ||
| 816 | ])); | ||
| 817 | } | ||
| 818 | } | ||
| 819 | |||
| 481 | if !issue.body.is_empty() { | 820 | if !issue.body.is_empty() { |
| 482 | lines.push(Line::raw("")); | 821 | lines.push(Line::raw("")); |
| 483 | for l in issue.body.lines() { | 822 | for l in issue.body.lines() { |
| @@ -556,6 +895,16 @@ fn build_patch_detail(patch: &PatchState) -> Text<'static> { | |||
| 556 | ]), | 895 | ]), |
| 557 | ]; | 896 | ]; |
| 558 | 897 | ||
| 898 | if let Some(ref fixes) = patch.fixes { | ||
| 899 | lines.push(Line::from(vec![ | ||
| 900 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), | ||
| 901 | Span::styled( | ||
| 902 | format!("{:.8}", fixes), | ||
| 903 | Style::default().fg(Color::Yellow), | ||
| 904 | ), | ||
| 905 | ])); | ||
| 906 | } | ||
| 907 | |||
| 559 | if !patch.body.is_empty() { | 908 | if !patch.body.is_empty() { |
| 560 | lines.push(Line::raw("")); | 909 | lines.push(Line::raw("")); |
| 561 | for l in patch.body.lines() { | 910 | for l in patch.body.lines() { |
| @@ -774,6 +1123,49 @@ fn colorize_diff(diff: &str, inline_comments: &[state::InlineComment]) -> Text<' | |||
| 774 | } | 1123 | } |
| 775 | 1124 | ||
| 776 | fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | 1125 | fn render_footer(frame: &mut Frame, app: &App, area: Rect) { |
| 1126 | match app.input_mode { | ||
| 1127 | InputMode::Search => { | ||
| 1128 | let max_query_len = (area.width as usize).saturating_sub(12); | ||
| 1129 | let display_query = if app.search_query.len() > max_query_len { | ||
| 1130 | &app.search_query[app.search_query.len() - max_query_len..] | ||
| 1131 | } else { | ||
| 1132 | &app.search_query | ||
| 1133 | }; | ||
| 1134 | let text = format!(" Search: {}_", display_query); | ||
| 1135 | let style = Style::default().bg(Color::Blue).fg(Color::White); | ||
| 1136 | let para = Paragraph::new(text).style(style); | ||
| 1137 | frame.render_widget(para, area); | ||
| 1138 | return; | ||
| 1139 | } | ||
| 1140 | InputMode::CreateTitle => { | ||
| 1141 | let max_len = (area.width as usize).saturating_sub(22); | ||
| 1142 | let display = if app.input_buf.len() > max_len { | ||
| 1143 | &app.input_buf[app.input_buf.len() - max_len..] | ||
| 1144 | } else { | ||
| 1145 | &app.input_buf | ||
| 1146 | }; | ||
| 1147 | let text = format!(" New issue - Title: {}_", display); | ||
| 1148 | let style = Style::default().bg(Color::Green).fg(Color::Black); | ||
| 1149 | let para = Paragraph::new(text).style(style); | ||
| 1150 | frame.render_widget(para, area); | ||
| 1151 | return; | ||
| 1152 | } | ||
| 1153 | InputMode::CreateBody => { | ||
| 1154 | let max_len = (area.width as usize).saturating_sub(21); | ||
| 1155 | let display = if app.input_buf.len() > max_len { | ||
| 1156 | &app.input_buf[app.input_buf.len() - max_len..] | ||
| 1157 | } else { | ||
| 1158 | &app.input_buf | ||
| 1159 | }; | ||
| 1160 | let text = format!(" New issue - Body: {}_ (Esc: skip)", display); | ||
| 1161 | let style = Style::default().bg(Color::Green).fg(Color::Black); | ||
| 1162 | let para = Paragraph::new(text).style(style); | ||
| 1163 | frame.render_widget(para, area); | ||
| 1164 | return; | ||
| 1165 | } | ||
| 1166 | InputMode::Normal => {} | ||
| 1167 | } | ||
| 1168 | |||
| 777 | let mode_hint = if app.tab == Tab::Patches { | 1169 | let mode_hint = if app.tab == Tab::Patches { |
| 778 | match app.mode { | 1170 | match app.mode { |
| 779 | ViewMode::Details => " d:diff", | 1171 | ViewMode::Details => " d:diff", |
| @@ -782,15 +1174,226 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | |||
| 782 | } else { | 1174 | } else { |
| 783 | "" | 1175 | "" |
| 784 | }; | 1176 | }; |
| 785 | let filter_hint = if app.show_all { | 1177 | let filter_hint = match app.status_filter { |
| 786 | "a:open only" | 1178 | StatusFilter::Open => "a:show all", |
| 1179 | StatusFilter::All => "a:closed", | ||
| 1180 | StatusFilter::Closed => "a:open only", | ||
| 1181 | }; | ||
| 1182 | let text = if let Some(ref msg) = app.status_msg { | ||
| 1183 | format!(" {}", msg) | ||
| 1184 | } else { | ||
| 1185 | format!( | ||
| 1186 | " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit", | ||
| 1187 | filter_hint, mode_hint | ||
| 1188 | ) | ||
| 1189 | }; | ||
| 1190 | let style = if app.status_msg.is_some() { | ||
| 1191 | Style::default().bg(Color::Yellow).fg(Color::Black) | ||
| 787 | } else { | 1192 | } else { |
| 788 | "a:show all" | 1193 | Style::default().bg(Color::DarkGray).fg(Color::White) |
| 789 | }; | 1194 | }; |
| 790 | let text = format!( | 1195 | let para = Paragraph::new(text).style(style); |
| 791 | " 1:issues 2:patches j/k:navigate Tab:pane {}{} r:refresh q:quit", | ||
| 792 | filter_hint, mode_hint | ||
| 793 | ); | ||
| 794 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); | ||
| 795 | frame.render_widget(para, area); | 1196 | frame.render_widget(para, area); |
| 796 | } | 1197 | } |
| 1198 | |||
| 1199 | #[cfg(test)] | ||
| 1200 | mod tests { | ||
| 1201 | use super::*; | ||
| 1202 | use crate::event::Author; | ||
| 1203 | |||
| 1204 | fn make_author() -> Author { | ||
| 1205 | Author { | ||
| 1206 | name: "test".into(), | ||
| 1207 | email: "test@test.com".into(), | ||
| 1208 | } | ||
| 1209 | } | ||
| 1210 | |||
| 1211 | fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState { | ||
| 1212 | IssueState { | ||
| 1213 | id: id.into(), | ||
| 1214 | title: title.into(), | ||
| 1215 | body: String::new(), | ||
| 1216 | status, | ||
| 1217 | close_reason: None, | ||
| 1218 | closed_by: None, | ||
| 1219 | labels: vec![], | ||
| 1220 | assignees: vec![], | ||
| 1221 | comments: vec![], | ||
| 1222 | created_at: String::new(), | ||
| 1223 | author: make_author(), | ||
| 1224 | } | ||
| 1225 | } | ||
| 1226 | |||
| 1227 | fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState { | ||
| 1228 | PatchState { | ||
| 1229 | id: id.into(), | ||
| 1230 | title: title.into(), | ||
| 1231 | body: String::new(), | ||
| 1232 | status, | ||
| 1233 | base_ref: "main".into(), | ||
| 1234 | head_commit: "abc123".into(), | ||
| 1235 | fixes: None, | ||
| 1236 | comments: vec![], | ||
| 1237 | inline_comments: vec![], | ||
| 1238 | reviews: vec![], | ||
| 1239 | created_at: String::new(), | ||
| 1240 | author: make_author(), | ||
| 1241 | } | ||
| 1242 | } | ||
| 1243 | |||
| 1244 | fn test_app() -> App { | ||
| 1245 | let issues = vec![ | ||
| 1246 | make_issue("i1", "Fix login bug", IssueStatus::Open), | ||
| 1247 | make_issue("i2", "Add dashboard feature", IssueStatus::Open), | ||
| 1248 | make_issue("i3", "Old closed issue", IssueStatus::Closed), | ||
| 1249 | ]; | ||
| 1250 | let patches = vec![ | ||
| 1251 | make_patch("p1", "Login fix patch", PatchStatus::Open), | ||
| 1252 | make_patch("p2", "Dashboard patch", PatchStatus::Closed), | ||
| 1253 | make_patch("p3", "Merged feature", PatchStatus::Merged), | ||
| 1254 | ]; | ||
| 1255 | App::new(issues, patches) | ||
| 1256 | } | ||
| 1257 | |||
| 1258 | // T010: visible_issues filters by search_query (case-insensitive) | ||
| 1259 | #[test] | ||
| 1260 | fn test_visible_issues_text_filter() { | ||
| 1261 | let mut app = test_app(); | ||
| 1262 | app.status_filter = StatusFilter::All; | ||
| 1263 | app.search_query = "login".into(); | ||
| 1264 | let visible = app.visible_issues(); | ||
| 1265 | assert_eq!(visible.len(), 1); | ||
| 1266 | assert_eq!(visible[0].title, "Fix login bug"); | ||
| 1267 | } | ||
| 1268 | |||
| 1269 | // T011: visible_patches filters by search_query (case-insensitive) | ||
| 1270 | #[test] | ||
| 1271 | fn test_visible_patches_text_filter() { | ||
| 1272 | let mut app = test_app(); | ||
| 1273 | app.status_filter = StatusFilter::All; | ||
| 1274 | app.search_query = "DASHBOARD".into(); | ||
| 1275 | let visible = app.visible_patches(); | ||
| 1276 | assert_eq!(visible.len(), 1); | ||
| 1277 | assert_eq!(visible[0].title, "Dashboard patch"); | ||
| 1278 | } | ||
| 1279 | |||
| 1280 | // T012: visible_issues returns all status-matching items when search_query is empty | ||
| 1281 | #[test] | ||
| 1282 | fn test_visible_issues_no_text_filter() { | ||
| 1283 | let mut app = test_app(); | ||
| 1284 | app.status_filter = StatusFilter::All; | ||
| 1285 | app.search_query.clear(); | ||
| 1286 | let visible = app.visible_issues(); | ||
| 1287 | assert_eq!(visible.len(), 3); | ||
| 1288 | } | ||
| 1289 | |||
| 1290 | // T019: StatusFilter::next() cycles Open → All → Closed → Open | ||
| 1291 | #[test] | ||
| 1292 | fn test_status_filter_cycle() { | ||
| 1293 | assert_eq!(StatusFilter::Open.next(), StatusFilter::All); | ||
| 1294 | assert_eq!(StatusFilter::All.next(), StatusFilter::Closed); | ||
| 1295 | assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open); | ||
| 1296 | } | ||
| 1297 | |||
| 1298 | // T020: visible_issues returns only closed when status_filter is Closed | ||
| 1299 | #[test] | ||
| 1300 | fn test_visible_issues_closed_filter() { | ||
| 1301 | let mut app = test_app(); | ||
| 1302 | app.status_filter = StatusFilter::Closed; | ||
| 1303 | let visible = app.visible_issues(); | ||
| 1304 | assert_eq!(visible.len(), 1); | ||
| 1305 | assert_eq!(visible[0].title, "Old closed issue"); | ||
| 1306 | } | ||
| 1307 | |||
| 1308 | // T021: visible_patches returns closed AND merged when status_filter is Closed | ||
| 1309 | #[test] | ||
| 1310 | fn test_visible_patches_closed_filter() { | ||
| 1311 | let mut app = test_app(); | ||
| 1312 | app.status_filter = StatusFilter::Closed; | ||
| 1313 | let visible = app.visible_patches(); | ||
| 1314 | assert_eq!(visible.len(), 2); | ||
| 1315 | assert!(visible.iter().any(|p| p.status == PatchStatus::Closed)); | ||
| 1316 | assert!(visible.iter().any(|p| p.status == PatchStatus::Merged)); | ||
| 1317 | } | ||
| 1318 | |||
| 1319 | // T025: combined status + text filter | ||
| 1320 | #[test] | ||
| 1321 | fn test_combined_filters() { | ||
| 1322 | let mut app = test_app(); | ||
| 1323 | app.status_filter = StatusFilter::Open; | ||
| 1324 | app.search_query = "login".into(); | ||
| 1325 | let visible = app.visible_issues(); | ||
| 1326 | assert_eq!(visible.len(), 1); | ||
| 1327 | assert_eq!(visible[0].title, "Fix login bug"); | ||
| 1328 | |||
| 1329 | // Same query with Closed filter should return nothing | ||
| 1330 | app.status_filter = StatusFilter::Closed; | ||
| 1331 | let visible = app.visible_issues(); | ||
| 1332 | assert_eq!(visible.len(), 0); | ||
| 1333 | } | ||
| 1334 | |||
| 1335 | // T026: Escape clears text filter but preserves status_filter | ||
| 1336 | #[test] | ||
| 1337 | fn test_escape_clears_text_preserves_status() { | ||
| 1338 | let mut app = test_app(); | ||
| 1339 | app.status_filter = StatusFilter::Closed; | ||
| 1340 | app.input_mode = InputMode::Search; | ||
| 1341 | app.search_query = "some query".into(); | ||
| 1342 | |||
| 1343 | // Simulate Escape | ||
| 1344 | app.input_mode = InputMode::Normal; | ||
| 1345 | app.search_query.clear(); | ||
| 1346 | |||
| 1347 | assert_eq!(app.status_filter, StatusFilter::Closed); | ||
| 1348 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 1349 | assert!(app.search_query.is_empty()); | ||
| 1350 | } | ||
| 1351 | |||
| 1352 | #[test] | ||
| 1353 | fn test_create_title_mode_clears_on_escape() { | ||
| 1354 | let mut app = test_app(); | ||
| 1355 | app.input_mode = InputMode::CreateTitle; | ||
| 1356 | app.input_buf = "partial title".into(); | ||
| 1357 | |||
| 1358 | // Simulate Escape | ||
| 1359 | app.input_mode = InputMode::Normal; | ||
| 1360 | app.input_buf.clear(); | ||
| 1361 | |||
| 1362 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 1363 | assert!(app.input_buf.is_empty()); | ||
| 1364 | } | ||
| 1365 | |||
| 1366 | #[test] | ||
| 1367 | fn test_create_title_transitions_to_body() { | ||
| 1368 | let mut app = test_app(); | ||
| 1369 | app.input_mode = InputMode::CreateTitle; | ||
| 1370 | app.input_buf = "My new issue".into(); | ||
| 1371 | |||
| 1372 | // Simulate Enter with non-empty title | ||
| 1373 | let title = app.input_buf.trim().to_string(); | ||
| 1374 | assert!(!title.is_empty()); | ||
| 1375 | app.create_title = title; | ||
| 1376 | app.input_buf.clear(); | ||
| 1377 | app.input_mode = InputMode::CreateBody; | ||
| 1378 | |||
| 1379 | assert_eq!(app.input_mode, InputMode::CreateBody); | ||
| 1380 | assert_eq!(app.create_title, "My new issue"); | ||
| 1381 | assert!(app.input_buf.is_empty()); | ||
| 1382 | } | ||
| 1383 | |||
| 1384 | #[test] | ||
| 1385 | fn test_empty_title_dismissed() { | ||
| 1386 | let mut app = test_app(); | ||
| 1387 | app.input_mode = InputMode::CreateTitle; | ||
| 1388 | app.input_buf = " ".into(); | ||
| 1389 | |||
| 1390 | // Simulate Enter with whitespace-only title | ||
| 1391 | let title = app.input_buf.trim().to_string(); | ||
| 1392 | if title.is_empty() { | ||
| 1393 | app.input_mode = InputMode::Normal; | ||
| 1394 | app.input_buf.clear(); | ||
| 1395 | } | ||
| 1396 | |||
| 1397 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 1398 | } | ||
| 1399 | } | ||