a73x

be69e78c

Filter, search and create issues from the TUI dashboard

a73x   2026-03-21 08:59

Commit message
Filter, search and create issues from the TUI dashboard

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 }