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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
//! Folding answers from Linear into the store, and deciding which of
//! them still belong on screen.
use super::*;
/// Identifies one of the paginated sub-lists, for index clamping.
#[derive(Debug, Clone, Copy)]
enum Field {
Projects,
Cycles,
}
impl App {
pub fn handle_message(&mut self, msg: Message) {
self.fold_message(msg);
// Each answer may be what a restore was waiting for.
self.advance_restore();
}
fn fold_message(&mut self, msg: Message) {
match msg {
Message::Teams(teams) => {
let preferred = self.restored_team(&teams).or_else(|| {
let default_team = self.default_team.as_ref()?;
teams
.iter()
.position(|t| t.name == *default_team || t.key == *default_team)
});
if let Some(idx) = preferred {
self.nav.team = idx;
}
self.store.teams = teams;
// A restore opens its own first page.
if self.restoring_destination() {
if let Some(team_id) = self.team_id() {
self.ensure_team_context(team_id);
}
return;
}
self.nav.dest = Nav::Team(self.nav.team, TeamSection::Issues);
if let Some(team_id) = self.team_id() {
self.ensure_team_context(team_id.clone());
self.request(Request::Issues {
team_id,
after: None,
preset: self.view.lists[IssueSource::Team].preset,
});
}
}
Message::Viewer(id) => {
self.store.viewer_id = Some(id);
if self.nav.dest == Nav::MyIssues {
self.reload_current_tab();
}
}
Message::TeamContext {
team_id,
states,
members,
} => {
// Kept whichever team is selected: it is filed under its own
// team, so it can only ever be offered for that team's issues.
self.outbox.team_contexts.remove(&team_id);
let waiting = self.popup_team_id() == Some(&team_id);
self.store
.team_contexts
.insert(team_id, TeamContext { states, members });
// A popup opened while this was loading starts on the
// issue's current value, as it would have if it were cached.
if waiting {
self.view.popup_index = self.popup_initial_index();
}
}
Message::Issues {
team_id,
preset,
page,
} => {
// A page for a team or preset the user has since left would
// file, say, another team's backlog under this team's Active.
if !self.is_current_team(&team_id)
|| preset != self.view.lists[IssueSource::Team].preset
{
return;
}
self.accept_issue_page(IssueSource::Team, page);
self.clear_status();
}
Message::MyIssues(page) => {
self.accept_issue_page(IssueSource::My, page);
self.clear_status();
}
Message::PaletteResults { seq, issues } => self.accept_palette_results(seq, issues),
Message::SearchResults {
term,
team_id,
issues,
} => {
// Results scoped to a team the user has left would be shown
// under the new team's name.
if team_id.is_some() && team_id != self.team_id() {
return;
}
self.nav.dest = Nav::Team(self.nav.team, TeamSection::Issues);
self.nav.screen = Screen::IssueList;
self.store.issues[IssueSource::Team].items = issues;
self.store.issues[IssueSource::Team].page_info = PageInfo::default();
self.view.lists[IssueSource::Team].filters.clear();
// Search answers "where is it", done or not; the Active slice
// would quietly hide half the matches. Set without refetching,
// which would replace the results with the team's list.
self.view.lists[IssueSource::Team].preset = Preset::All;
self.view.lists[IssueSource::Team].search.clear();
self.view.lists[IssueSource::Team].selected = 0;
self.nav.global_search = Some(term);
}
Message::ViewProjects { view_id, page } => {
if !self.accepts_view_page(&view_id, page.append, ViewKind::Projects) {
return;
}
let append = page.append;
self.store.view_projects.accept(page);
if !append {
self.view.selected_view_project_index = 0;
}
self.store.loaded_view_projects_id = Some(view_id);
self.clear_status();
}
Message::Projects { team_id, page } => {
if !self.is_current_team(&team_id) {
return;
}
self.store.projects.accept(page);
self.clamp(Field::Projects);
self.clear_status();
}
Message::Cycles { team_id, page } => {
if !self.is_current_team(&team_id) {
return;
}
self.store.cycles.accept(page);
self.clamp(Field::Cycles);
self.clear_status();
}
Message::CustomViews(views) => {
// Keep the open view pointing at the same view across a refetch;
// the index alone would silently swap which one is on screen.
let open = match self.nav.dest {
Nav::View(i) => self.store.custom_views.get(i).map(|v| v.id.clone()),
_ => None,
};
self.store.set_custom_views(views);
if let Some(id) = open {
match self.store.custom_views.iter().position(|v| v.id == id) {
Some(i) => self.nav.dest = Nav::View(i),
None => self.activate(Nav::Views),
}
}
self.view.selected_view_index = self
.view
.selected_view_index
.min(self.listed_views().len().saturating_sub(1));
}
Message::Favorites(favorites) => self.store.set_favorites(favorites),
Message::ViewIssues { view_id, page } => {
if !self.accepts_view_page(&view_id, page.append, ViewKind::Issues) {
return;
}
self.store.loaded_view_id = Some(view_id);
self.accept_issue_page(IssueSource::View, page);
self.clear_status();
}
Message::IssueDetail(issue) => {
self.restored_issue_loaded(&issue);
self.store.refresh_issue(*issue);
}
Message::ProjectIssues { project_id, page } => {
if self.nav.current_project.as_ref().map(|p| &p.id) != Some(&project_id) {
return;
}
self.accept_issue_page(IssueSource::Project, page);
}
Message::CycleIssues { cycle_id, page } => {
if self.nav.current_cycle.as_ref().map(|c| &c.id) != Some(&cycle_id) {
return;
}
self.accept_issue_page(IssueSource::Cycle, page);
}
Message::IssueCreated { team_id, issue } => {
self.set_status(format!("Created {}", issue.identifier));
// Another team's list is not on screen; its next fetch will
// include the issue anyway.
if !self.is_current_team(&team_id) {
return;
}
// Show it immediately rather than waiting for a refetch, with
// the cursor on it — found by id, since grouping decides where
// in the list it lands.
let id = issue.id.clone();
self.store.issues[IssueSource::Team].items.insert(0, *issue);
if self.issue_source() == IssueSource::Team && self.nav.screen == Screen::IssueList
{
self.restore_issue_selection(Some(&id));
}
}
Message::Mutated(what) => {
self.set_status(what);
// A posted comment clears the cached thread; pull it back in.
self.queue_detail_fetches();
}
Message::Failed { request, error } => {
if let Request::IssueDetail { issue_id } = request.as_ref()
&& self.restored_issue_failed(issue_id)
{
return;
}
self.restore_lost(&request);
if let Request::Herdr(handoff) = request.as_ref() {
self.handoff_failed(handoff, &error);
return;
}
// A failed page must be retryable.
if let Some(cursor) = request.cursor() {
self.outbox.prefetched.remove(cursor);
}
// A palette search that failed is not worth an error over
// the palette; the local matches are still there.
if let Request::PaletteSearch { seq, .. } = request.as_ref() {
if *seq == self.view.palette.seq {
self.view.palette.searching = false;
}
self.set_status(format!("Search failed: {error}"));
return;
}
// Reopening the popup asks again.
if let Request::TeamContext { team_id } = request.as_ref() {
self.outbox.team_contexts.remove(team_id);
}
// The change is already on screen; ask Linear what the issue
// really looks like now rather than guess what to undo.
if let Some(issue_id) = request.patched_issue() {
self.request(Request::IssueDetail {
issue_id: issue_id.clone(),
});
}
self.set_error(format!("{}: {error}", request.failure()));
}
}
}
fn is_current_team(&self, team_id: &TeamId) -> bool {
self.current_team().is_some_and(|t| &t.id == team_id)
}
/// Whether a page of saved view `view_id` belongs on screen.
///
/// A first page is taken only for the view that is open; a next page only
/// when the list it would extend is that view's.
fn accepts_view_page(&self, view_id: &CustomViewId, append: bool, kind: ViewKind) -> bool {
if append {
let loaded = match kind {
ViewKind::Issues => &self.store.loaded_view_id,
ViewKind::Projects => &self.store.loaded_view_projects_id,
};
return loaded.as_ref() == Some(view_id);
}
matches!(self.nav.dest, Nav::View(i) if self.store.custom_views.get(i).is_some_and(|v| &v.id == view_id))
}
/// Fold a page into one of the issue lists, keeping the cursor on the
/// issue it was on.
///
/// Grouping decides where each row lands, so even an appended page can
/// slot issues in above the cursor; restoring by index would quietly move
/// the selection — and whatever popup is open — to another issue.
fn accept_issue_page(&mut self, source: IssueSource, page: Page<Issue>) {
let on_screen = source == self.issue_source();
let keep = if on_screen {
let restored = (!page.append)
.then(|| self.take_restored_selection(source))
.flatten();
restored.or_else(|| self.selected_issue_id())
} else {
None
};
let append = page.append;
self.store.issues[source].accept_issues(page);
if on_screen {
self.restore_issue_selection(keep.as_ref());
} else if !append {
*self.selected_index_of(source) = 0;
}
}
/// Keep a selection index inside its (possibly shrunken) list.
fn clamp(&mut self, field: Field) {
let (len, index) = match field {
Field::Projects => (
self.store.projects.items.len(),
&mut self.view.selected_project_index,
),
Field::Cycles => (
self.store.cycles.items.len(),
&mut self.view.selected_cycle_index,
),
};
*index = (*index).min(len.saturating_sub(1));
}
fn selected_issue_id(&self) -> Option<IssueId> {
self.visible_issues()
.get(self.selected_index())
.map(|i| i.id.clone())
}
/// Put the cursor back on the issue it was on, by identity.
///
/// A refetch reorders the list — `updatedAt` moves the moment anyone
/// touches an issue — so restoring by row index would quietly select a
/// different issue than the one the user was looking at.
fn restore_issue_selection(&mut self, id: Option<&IssueId>) {
let index = id
.and_then(|id| self.visible_issues().iter().position(|i| &i.id == id))
.unwrap_or(0);
*self.selected_index_mut() = index;
}
}