xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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>,
    /// Indices into `issues` after client search.
    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>,
    pub viewport_rows: usize,
    pub comments_popup: Option<String>,
    pub undo_stack: Vec<LinearIssue>,
    pub command_history: Vec<String>,
    pub command_history_cursor: Option<usize>,
}

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,
            viewport_rows: 20,
            comments_popup: None,
            undo_stack: Vec::new(),
            command_history: Vec::new(),
            command_history_cursor: None,
        }
    }

    pub fn page_size(&self) -> usize {
        self.viewport_rows.clamp(5, 80)
    }

    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() {
            // After leaving search, keep last committed query in filter.query instead.
            self.filter
                .query
                .as_deref()
                .unwrap_or("")
                .trim()
                .to_ascii_lowercase()
        } else {
            // Live search buffer only while in Search mode; also honor sticky filter.query.
            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 update_selected_issue<F>(&mut self, mut update: F)
    where
        F: FnMut(&mut LinearIssue),
    {
        let Some(issue_idx) = self.visible.get(self.selected).copied() else {
            return;
        };
        if let Some(issue) = self.issues.get_mut(issue_idx) {
            update(issue);
        }
        self.recompute_visible();
    }

    pub fn push_undo(&mut self, issue: LinearIssue) {
        self.undo_stack.push(issue);
        if self.undo_stack.len() > 20 {
            self.undo_stack.remove(0);
        }
    }

    pub fn record_command(&mut self, command: String) {
        let command = command.trim();
        if command.is_empty() {
            return;
        }
        if self
            .command_history
            .last()
            .is_some_and(|last| last == command)
        {
            self.command_history_cursor = None;
            return;
        }
        self.command_history.push(command.to_string());
        if self.command_history.len() > 100 {
            self.command_history.remove(0);
        }
        self.command_history_cursor = None;
    }

    pub fn previous_command(&mut self) {
        if self.command_history.is_empty() {
            return;
        }
        let next = self
            .command_history_cursor
            .map(|cursor| cursor.saturating_sub(1))
            .unwrap_or_else(|| self.command_history.len().saturating_sub(1));
        self.command_history_cursor = Some(next);
        self.input = self.command_history[next].clone();
    }

    pub fn next_command(&mut self) {
        let Some(cursor) = self.command_history_cursor else {
            return;
        };
        if cursor + 1 >= self.command_history.len() {
            self.command_history_cursor = None;
            self.input.clear();
        } else {
            let next = cursor + 1;
            self.command_history_cursor = Some(next);
            self.input = self.command_history[next].clone();
        }
    }

    pub fn last_command(&self) -> Option<String> {
        self.command_history.last().cloned()
    }

    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);
    }
}