kanban-tui 0.9.0

Terminal user interface for the kanban project management tool
Documentation
use super::{App, AppMode};
use crate::view_strategy::UnifiedViewStrategy;
use kanban_domain::{filter_and_sort_boards, Board, BoardListFilter, Card, KanbanResult};
use kanban_view::view_strategy::{ViewRefreshContext, ViewStrategy};
use std::collections::HashMap;
use uuid::Uuid;

impl App {
    /// Resolve the board the user is currently acting on / viewing, by identity.
    /// This is the single, archival-agnostic active-board accessor every
    /// operation and view uses: a board is a board whether its head is live or
    /// archived. Returns `None` when no board is active (browsing the projects
    /// list without opening one).
    ///
    /// Note: `prepare_frame` inlines the equivalent resolution instead of calling
    /// this helper because it also needs a mutable borrow of `self.view.strategy`
    /// in the same scope, which Rust's NLL cannot split through a method boundary.
    /// All other call sites use this helper directly.
    pub fn active_board(&self) -> Option<&Board> {
        self.selection
            .active_board_id
            .and_then(|id| self.model.board_by_id_state(id).loaded().copied())
    }

    /// The board a board-scoped operation acts on: the active board (by id) if
    /// one is open, otherwise the board highlighted in the currently displayed
    /// projects set. Board-agnostic — resolves live OR archived boards uniformly,
    /// so board detail, sprints, and columns work identically for either.
    pub fn board_in_context(&self) -> Option<&Board> {
        let id = self
            .selection
            .active_board_id
            .or_else(|| self.board_list.get_selected_board_id())?;
        self.model.board_by_id_state(id).loaded().copied()
    }

    /// The card subset the tasks panel currently displays: the archived cards
    /// under the archived-cards view (stack-aware, so a confirm dialog opened
    /// over it still resolves the archived set), the live cards otherwise. A
    /// borrow of the partition cached on `load_from_snapshot` — no per-frame
    /// filter or clone. The SOLE card-side live/archived selector.
    pub fn displayed_cards(&self) -> &[Card] {
        let want_archived = matches!(self.get_base_mode(), AppMode::ArchivedCardsView);
        self.model.displayed_cards(want_archived)
    }

    /// The board set the projects panel currently displays: the archived heads
    /// when the panel is toggled to the archived set, the live boards otherwise.
    /// This is the SOLE place the live/archived distinction affects behavior —
    /// the projects panel choosing which set to render and index selection into.
    /// Everything downstream (operations, navigation, resolution) is board-
    /// agnostic and resolves the active board by id via `active_board`.
    ///
    /// Stack-aware: the choice keys off `get_base_mode()`, not the raw `mode`, so
    /// a confirm dialog opened over the archived view (mode == Dialog(..)) still
    /// resolves the archived heads — the underlay-bug fix. Applies the projects
    /// panel's own search query (`filter.board_search`) via the shared domain
    /// filter engine on top of the cached, already-sorted model partition, so
    /// callers that mutate the board sort directly (e.g. `apply_board_sort`)
    /// see it reflected here without needing a `prepare_frame` round-trip —
    /// matching the pre-search behavior of this accessor. Owned `Vec<Board>`
    /// rather than a borrow because the search filter must run fresh on every
    /// call (board counts are small; this mirrors how card search re-filters
    /// per redraw).
    pub fn displayed_boards(&self) -> Vec<Board> {
        let want_archived = matches!(self.get_base_mode(), AppMode::ArchivedBoardsView);
        let boards = self.model.displayed_boards(want_archived);
        let filter = BoardListFilter {
            search: self.filter.board_search.active_query().map(str::to_string),
            ..Default::default()
        };
        filter_and_sort_boards(boards, &filter, &HashMap::new(), None)
    }

    /// Reload the whole view model from the store. I/O. Call after a mutation,
    /// after an external change, or on a cold path (startup, backend swap).
    pub fn reload_model(&mut self) {
        match self.ctx.snapshot() {
            Ok(snapshot) => self.model.load_from_snapshot(snapshot),
            Err(e) => tracing::warn!("Failed to load model from store: {e}"),
        }
    }

    /// Rebuild the display partitions and task lists from the cached model.
    /// Pure: performs no store access.
    pub fn prepare_frame(&mut self) {
        // Single card-side selector: borrow the cached displayed subset (stack-
        // aware base mode). No per-frame filter/clone — the partition was built
        // on load. Resolved via `self.model` directly (not `self.displayed_cards`)
        // so the borrow is scoped to `self.model` and splits cleanly from the
        // `&mut self.view.strategy` borrow `refresh_task_lists` takes below.
        let want_archived_cards = matches!(self.get_base_mode(), AppMode::ArchivedCardsView);
        let cards_for_display: &[Card] = self.model.displayed_cards(want_archived_cards);

        // Board resolution: resolved via `self.model` directly (rather than
        // `active_board` / `displayed_boards`, which borrow all of `self`) so the
        // borrow stays scoped to `self.model` and NLL can split it from the
        // `&mut self.view.strategy` borrow `refresh_task_lists` needs. When no
        // board is active (browsing the projects list), fall back to the
        // highlighted board in the currently displayed set — the same cached,
        // base-mode-selected subset the projects panel indexes into — so the
        // tasks preview tracks the cursor. The id is extracted and re-resolved by
        // id so the returned borrow is tied to `self.model`, not a temporary.
        let board_ids: Vec<Uuid> = self.displayed_boards().iter().map(|b| b.id).collect();
        self.board_list.update_boards(board_ids);
        let highlighted_id: Option<Uuid> = self.board_list.get_selected_board_id();
        let board_id: Option<Uuid> = self.selection.active_board_id.or(highlighted_id);
        let board: Option<&Board> =
            board_id.and_then(|id| self.model.board_by_id_state(id).loaded().copied());

        if let Some(board) = board {
            let search_query = if self.filter.search.is_active {
                Some(self.filter.search.query())
            } else {
                None
            };
            let ctx = ViewRefreshContext {
                board,
                all_cards: cards_for_display,
                all_columns: self.model.columns(),
                all_sprints: self.model.sprints(),
                active_sprint_filters: self.filter.active_sprint_filters.clone(),
                hide_assigned_cards: self.filter.hide_assigned_cards,
                search_query,
            };
            self.view.strategy.refresh_task_lists(&ctx);
        }
        self.sync_card_list_component();
    }

    /// Undo the last action
    pub fn undo(&mut self) -> KanbanResult<()> {
        if self.ctx.undo()? {
            self.reload_model();
            self.needs_redraw = true;
        } else {
            self.set_error("Nothing to undo".to_string());
        }
        Ok(())
    }

    /// Redo the last undone action
    pub fn redo(&mut self) -> KanbanResult<()> {
        if self.ctx.redo()? {
            self.reload_model();
            self.needs_redraw = true;
        } else {
            self.set_error("Nothing to redo".to_string());
        }
        Ok(())
    }

    pub fn sync_card_list_component(&mut self) {
        if let Some(active_list) = self.view.strategy.get_active_task_list() {
            self.view
                .card_list_component
                .update_cards(active_list.cards.clone());
        }
    }

    pub fn switch_view_strategy(&mut self, task_list_view: kanban_domain::TaskListView) {
        let new_strategy: Box<dyn ViewStrategy> = match task_list_view {
            kanban_domain::TaskListView::Flat => Box::new(UnifiedViewStrategy::flat()),
            kanban_domain::TaskListView::GroupedByColumn => {
                Box::new(UnifiedViewStrategy::grouped())
            }
            kanban_domain::TaskListView::ColumnView => Box::new(UnifiedViewStrategy::kanban()),
        };

        self.view.strategy = new_strategy;
    }
}