use crate::commands::linear_cmd::issues::{
issue_matches_query, sort_issues, LinearIssue, ListIssuesFilter,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Search,
Command,
Help,
EditTitle,
EditComment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
List,
Detail,
}
pub struct App {
pub filter: ListIssuesFilter,
pub me_active: bool,
pub issues: Vec<LinearIssue>,
pub visible: Vec<usize>,
pub selected: usize,
pub list_offset: usize,
pub mode: Mode,
pub view: View,
pub input: String,
pub status: String,
pub error: Option<String>,
}
impl App {
pub fn new(filter: ListIssuesFilter) -> Self {
let me_active = filter.assignee_id.is_some();
Self {
filter,
me_active,
issues: Vec::new(),
visible: Vec::new(),
selected: 0,
list_offset: 0,
mode: Mode::Normal,
view: View::List,
input: String::new(),
status: String::new(),
error: None,
}
}
pub fn page_size(&self) -> usize {
12
}
pub fn recompute_visible(&mut self) {
sort_issues(&mut self.issues, self.filter.sort, self.filter.sort_asc);
let q = self.search_query();
self.visible = self
.issues
.iter()
.enumerate()
.filter(|(_, issue)| issue_matches_query(issue, &q))
.map(|(i, _)| i)
.collect();
if self.selected >= self.visible.len() {
self.selected = self.visible.len().saturating_sub(1);
}
self.ensure_visible();
}
pub fn apply_search_filter(&mut self) {
self.recompute_visible();
}
fn search_query(&self) -> String {
if self.mode == Mode::Search {
self.input.trim().to_ascii_lowercase()
} else if self.mode == Mode::Normal && !self.input.is_empty() {
self.filter
.query
.as_deref()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
} else {
let sticky = self
.filter
.query
.as_deref()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
sticky
}
}
pub fn selected_issue(&self) -> Option<&LinearIssue> {
self.visible
.get(self.selected)
.and_then(|&idx| self.issues.get(idx))
}
pub fn move_sel(&mut self, delta: i32) {
if self.visible.is_empty() {
return;
}
let len = self.visible.len() as i32;
let mut next = self.selected as i32 + delta;
if next < 0 {
next = 0;
}
if next >= len {
next = len - 1;
}
self.selected = next as usize;
self.ensure_visible();
}
pub fn ensure_visible(&mut self) {
let page = self.page_size().max(1);
if self.selected < self.list_offset {
self.list_offset = self.selected;
} else if self.selected >= self.list_offset + page {
self.list_offset = self.selected + 1 - page;
}
}
pub fn next_search_match(&mut self, forward: bool) {
if self.visible.is_empty() {
return;
}
let q = if self.mode == Mode::Search {
self.input.trim().to_ascii_lowercase()
} else {
self.filter
.query
.as_deref()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
};
if q.is_empty() {
return;
}
let len = self.visible.len();
for step in 1..=len {
let idx = if forward {
(self.selected + step) % len
} else {
(self.selected + len - step) % len
};
let issue_idx = self.visible[idx];
if issue_matches_query(&self.issues[issue_idx], &q) {
self.selected = idx;
self.ensure_visible();
return;
}
}
}
pub fn replace_issue(&mut self, updated: LinearIssue) {
let id = updated.id.clone();
if let Some(pos) = self.issues.iter().position(|i| i.id == id) {
self.issues[pos] = updated;
self.recompute_visible();
if let Some(vpos) = self.visible.iter().position(|&i| self.issues[i].id == id) {
self.selected = vpos;
self.ensure_visible();
}
}
}
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.error = Some(msg.into());
}
pub fn toast_ok(&mut self, msg: impl Into<String>) {
self.error = None;
self.status = msg.into();
}
pub fn filter_chips(&self) -> String {
let mut parts = Vec::new();
if self.me_active {
parts.push("me".to_string());
} else if self.filter.assignee_id.is_some() {
parts.push("assignee".to_string());
}
if let Some(state) = &self.filter.state {
parts.push(format!("state:{state}"));
}
if self.filter.team_id.is_some() {
parts.push("team".to_string());
}
if self.filter.include_completed {
parts.push("incl-done".to_string());
}
if !self.filter.labels.is_empty() {
parts.push(format!("labels:{}", self.filter.labels.join(",")));
}
if let Some(q) = &self.filter.query {
if !q.trim().is_empty() {
parts.push(format!("q:{q}"));
}
}
parts.push(format!(
"sort:{}{}",
self.filter.sort.as_str(),
if self.filter.sort_asc { "↑" } else { "↓" }
));
if parts.is_empty() {
"no filters".into()
} else {
parts.join(" · ")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::linear_cmd::issues::IssueSortField;
fn sample(id: &str, title: &str, pri: i32) -> LinearIssue {
LinearIssue {
id: id.into(),
identifier: id.into(),
title: title.into(),
description: None,
priority: pri,
url: None,
state_id: None,
state_name: Some("Todo".into()),
state_type: Some("unstarted".into()),
assignee_id: None,
assignee_name: None,
team_id: None,
team_key: None,
team_name: None,
labels: vec![],
updated_at: Some("2026-01-02T00:00:00Z".into()),
created_at: Some("2026-01-01T00:00:00Z".into()),
}
}
#[test]
fn search_filters_visible() {
let mut app = App::new(ListIssuesFilter {
sort: IssueSortField::Priority,
sort_asc: true,
..Default::default()
});
app.issues = vec![
sample("A-1", "auth bug", 1),
sample("A-2", "billing", 2),
sample("A-3", "auth hardening", 3),
];
app.mode = Mode::Search;
app.input = "auth".into();
app.recompute_visible();
assert_eq!(app.visible.len(), 2);
}
}