1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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;
}
}