Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/issue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ impl std::str::FromStr for Priority {
}

/// issue status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Todo,
Expand Down
117 changes: 114 additions & 3 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! TUI application state and logic.

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

use crate::config::Config;
use crate::error::{BrdError, Result};
Expand Down Expand Up @@ -40,6 +40,8 @@ pub enum InputMode {
EditPriority { issue_id: String, selected: usize },
/// editing issue status
EditStatus { issue_id: String, selected: usize },
/// filtering issues in the all pane
Filter(String),
}

/// TUI application state.
Expand All @@ -50,6 +52,8 @@ pub struct App {
pub ready_issues: Vec<String>,
/// all issues (sorted)
pub all_issues: Vec<String>,
/// filtered all issues (when filter is active)
pub filtered_all_issues: Vec<String>,
/// in-progress issues (sorted by age)
pub in_progress_issues: Vec<String>,
/// recently completed issues (sorted by updated_at)
Expand Down Expand Up @@ -82,6 +86,10 @@ pub struct App {
pub config: Config,
/// selected dependency index in detail pane
pub detail_dep_selected: Option<usize>,
/// filter query for all issues pane
pub all_filter_query: String,
/// status filter for all issues (empty means show all)
pub all_status_filter: HashSet<Status>,
}

const RECENT_DONE_LIMIT: usize = 8;
Expand All @@ -95,6 +103,7 @@ impl App {
issues: HashMap::new(),
ready_issues: Vec::new(),
all_issues: Vec::new(),
filtered_all_issues: Vec::new(),
in_progress_issues: Vec::new(),
recent_done_issues: Vec::new(),
ready_selected: 0,
Expand All @@ -111,6 +120,8 @@ impl App {
input_mode: InputMode::Normal,
config,
detail_dep_selected: None,
all_filter_query: String::new(),
all_status_filter: HashSet::new(),
};
app.reload_issues(paths)?;
Ok(app)
Expand Down Expand Up @@ -183,12 +194,109 @@ impl App {
}

self.reset_dep_selection();
self.apply_filter();
if show_message {
self.message = Some("refreshed".to_string());
}
Ok(())
}

/// apply the current filter to the all issues list.
pub fn apply_filter(&mut self) {
let query = self.all_filter_query.to_lowercase();
self.filtered_all_issues = self
.all_issues
.iter()
.filter(|id| {
let Some(issue) = self.issues.get(*id) else {
return false;
};
// check status filter (empty means show all)
if !self.all_status_filter.is_empty()
&& !self.all_status_filter.contains(&issue.status())
{
return false;
}
// check query filter
if !query.is_empty() && !issue.title().to_lowercase().contains(&query) {
return false;
}
true
})
.cloned()
.collect();

// clamp selection to filtered list
// use filtered list length directly since we just populated it
let visible_len = if self.has_filter() {
self.filtered_all_issues.len()
} else {
self.all_issues.len()
};
if self.all_selected >= visible_len && visible_len > 0 {
self.all_selected = visible_len - 1;
}
if self.all_offset >= visible_len {
self.all_offset = 0;
}
}

/// returns true if a filter is currently active.
pub fn has_filter(&self) -> bool {
!self.all_filter_query.is_empty() || !self.all_status_filter.is_empty()
}

/// get the visible all issues list (filtered or unfiltered).
pub fn visible_all_issues(&self) -> &Vec<String> {
if self.has_filter() {
&self.filtered_all_issues
} else {
&self.all_issues
}
}

/// toggle a status in the filter.
pub fn toggle_status_filter(&mut self, status: Status) {
if self.all_status_filter.contains(&status) {
self.all_status_filter.remove(&status);
} else {
self.all_status_filter.insert(status);
}
self.apply_filter();
self.message = None;
}

/// clear all filters.
pub fn clear_filter(&mut self) {
self.all_filter_query.clear();
self.all_status_filter.clear();
self.apply_filter();
self.message = Some("filter cleared".to_string());
}

/// start filter input mode.
pub fn start_filter(&mut self) {
self.input_mode = InputMode::Filter(self.all_filter_query.clone());
self.active_pane = ActivePane::All;
self.message = None;
}

/// cancel filter input.
pub fn cancel_filter(&mut self) {
self.input_mode = InputMode::Normal;
self.message = None;
}

/// confirm filter input.
pub fn confirm_filter(&mut self) {
if let InputMode::Filter(query) = &self.input_mode {
self.all_filter_query = query.clone();
self.apply_filter();
}
self.input_mode = InputMode::Normal;
self.message = None;
}

/// get the currently selected issue id.
pub fn selected_issue_id(&self) -> Option<&str> {
if self.view_mode == ViewMode::Live {
Expand All @@ -202,7 +310,10 @@ impl App {
.ready_issues
.get(self.ready_selected)
.map(|s| s.as_str()),
ActivePane::All => self.all_issues.get(self.all_selected).map(|s| s.as_str()),
ActivePane::All => self
.visible_all_issues()
.get(self.all_selected)
.map(|s| s.as_str()),
}
}

Expand Down Expand Up @@ -259,7 +370,7 @@ impl App {
}
}
ActivePane::All => {
if self.all_selected + 1 < self.all_issues.len() {
if self.all_selected + 1 < self.visible_all_issues().len() {
self.all_selected += 1;
}
}
Expand Down
39 changes: 39 additions & 0 deletions src/tui/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,37 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result<b
}
return Ok(false);
}
InputMode::Filter(current) => {
match key.code {
KeyCode::Esc => app.cancel_filter(),
KeyCode::Enter => app.confirm_filter(),
KeyCode::Backspace => {
let mut s = current.clone();
s.pop();
app.input_mode = InputMode::Filter(s);
}
// toggle status filters with 1-4
KeyCode::Char('1') => {
app.toggle_status_filter(crate::issue::Status::Todo);
}
KeyCode::Char('2') => {
app.toggle_status_filter(crate::issue::Status::Doing);
}
KeyCode::Char('3') => {
app.toggle_status_filter(crate::issue::Status::Done);
}
KeyCode::Char('4') => {
app.toggle_status_filter(crate::issue::Status::Skip);
}
KeyCode::Char(c) => {
let mut s = current.clone();
s.push(c);
app.input_mode = InputMode::Filter(s);
}
_ => {}
}
return Ok(false);
}
InputMode::Normal => {}
}

Expand Down Expand Up @@ -225,6 +256,14 @@ fn handle_key_event(app: &mut App, paths: &RepoPaths, key: KeyEvent) -> Result<b
KeyCode::Char('v') => app.toggle_live_view(),
KeyCode::Enter => app.open_selected_dependency(),

// filter
KeyCode::Char('/') => app.start_filter(),
KeyCode::Esc => {
if app.has_filter() {
app.clear_filter();
}
}

// help
KeyCode::Char('?') => app.toggle_help(),

Expand Down
98 changes: 87 additions & 11 deletions src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ fn draw_header(f: &mut Frame, area: Rect, app: &App) {

fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let msg = app.message.as_deref().unwrap_or("");
let help = "[a]dd [e]dit [s]tart [d]one [r]efresh [v]live [↑↓/jk]nav [Tab]switch [h/l]dep [enter]open dep [?]help [q]uit";
let help = "[a]dd [e]dit [s]tart [d]one [r]efresh [/]filter [v]live [↑↓/jk]nav [Tab]switch [?]help [q]uit";
let text = if msg.is_empty() {
help.to_string()
} else {
Expand Down Expand Up @@ -291,7 +291,35 @@ fn draw_all_list(f: &mut Frame, area: Rect, app: &mut App) {
Style::default().fg(Color::DarkGray)
};

let title = format!(" All ({}) ", app.all_issues.len());
// build title with filter info
let visible = app.visible_all_issues();
let title = if app.has_filter() {
let mut filter_parts = Vec::new();
if !app.all_filter_query.is_empty() {
filter_parts.push(format!("\"{}\"", app.all_filter_query));
}
if !app.all_status_filter.is_empty() {
let statuses: Vec<&str> = app
.all_status_filter
.iter()
.map(|s| match s {
crate::issue::Status::Todo => "T",
crate::issue::Status::Doing => "D",
crate::issue::Status::Done => "✓",
crate::issue::Status::Skip => "⊘",
})
.collect();
filter_parts.push(statuses.join(""));
}
format!(
" All ({}/{}) [{}] ",
visible.len(),
app.all_issues.len(),
filter_parts.join(" ")
)
} else {
format!(" All ({}) ", app.all_issues.len())
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
Expand All @@ -301,8 +329,7 @@ fn draw_all_list(f: &mut Frame, area: Rect, app: &mut App) {
let title_width = area.width.saturating_sub(16) as usize;
let view_height = block.inner(area).height as usize;

let items: Vec<ListItem> = app
.all_issues
let items: Vec<ListItem> = visible
.iter()
.enumerate()
.map(|(i, id)| {
Expand Down Expand Up @@ -348,17 +375,13 @@ fn draw_all_list(f: &mut Frame, area: Rect, app: &mut App) {
})
.collect();

let selected = if app.all_issues.is_empty() {
let visible_len = visible.len();
let selected = if visible_len == 0 {
None
} else {
Some(app.all_selected)
};
update_offset(
&mut app.all_offset,
selected,
app.all_issues.len(),
view_height,
);
update_offset(&mut app.all_offset, selected, visible_len, view_height);
let mut state = ListState::default()
.with_selected(selected)
.with_offset(app.all_offset);
Expand Down Expand Up @@ -559,6 +582,14 @@ fn draw_help(f: &mut Frame, area: Rect) {
Line::from(" r Refresh issues from disk"),
Line::from(" v Toggle live view"),
Line::from(""),
Line::from(Span::styled(
"Filter (All pane)",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" / Open filter dialog"),
Line::from(" 1-4 Toggle status (1=todo 2=doing 3=done 4=skip)"),
Line::from(" Esc Clear filter"),
Line::from(""),
Line::from(Span::styled(
"Other",
Style::default().add_modifier(Modifier::BOLD),
Expand Down Expand Up @@ -811,6 +842,51 @@ fn draw_input_dialog(f: &mut Frame, app: &App) {
let list = List::new(items);
f.render_widget(list, chunks[1]);
}
InputMode::Filter(query) => {
let block = Block::default()
.title(" Filter (Enter to apply, Esc to cancel) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));

let inner = block.inner(area);
f.render_widget(block, area);

let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);

let hint = Paragraph::new("Type to filter by title. Use 1-4 to toggle status filters.")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(hint, chunks[0]);

let input =
Paragraph::new(format!("/{}_", query)).style(Style::default().fg(Color::White));
f.render_widget(input, chunks[1]);

// show current status filter
let status_line = if app.all_status_filter.is_empty() {
"Status: all".to_string()
} else {
let statuses: Vec<&str> = [
(crate::issue::Status::Todo, "todo"),
(crate::issue::Status::Doing, "doing"),
(crate::issue::Status::Done, "done"),
(crate::issue::Status::Skip, "skip"),
]
.iter()
.filter(|(s, _)| app.all_status_filter.contains(s))
.map(|(_, name)| *name)
.collect();
format!("Status: {}", statuses.join(", "))
};
let status = Paragraph::new(status_line).style(Style::default().fg(Color::Cyan));
f.render_widget(status, chunks[2]);
}
InputMode::Normal => {}
}
}
Expand Down