1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 pub(in crate::app) fn view_projects() -> BTreeSet<String> {
13 BTreeSet::new()
14 }
15
16 pub(in crate::app) fn view_config_path(&self) -> PathBuf {
18 self.store
19 .config_path()
20 .unwrap_or_else(|_| PathBuf::from(".").join("tix.toml"))
21 }
22
23 pub(in crate::app) fn board_config(&self) -> BoardConfiguration {
30 match &self.view_session {
31 Some(session) if session.visibility => session
32 .marker
33 .applied_plan()
34 .effective_board(self.active_board_config()),
35 _ => self.active_board_config().clone(),
36 }
37 }
38
39 pub fn active_view_id(&self) -> Option<&ViewId> {
46 self.view_session
47 .as_ref()
48 .map(|session| session.marker.view_id())
49 }
50
51 pub fn live_plan(&self) -> ViewPlan {
60 let mut plan = ViewPlan::from_parts(
61 self.active_structured_filter().clone(),
62 self.filter.as_deref().unwrap_or_default(),
63 );
64 if let Some(session) = &self.view_session
65 && session.visibility
66 && let Some(visibility) = session.marker.applied_plan().visibility()
67 {
68 plan = plan.with_visibility(visibility.clone());
69 }
70 plan
71 }
72
73 pub fn capture_plan(&self) -> ViewPlan {
80 let board = self.board_config();
81 self.live_plan().with_visibility(ViewVisibility::new(
82 board.hidden_columns.clone(),
83 Some(board.show_mismatch),
84 ))
85 }
86
87 pub fn view_divergence(&self) -> Option<ViewDivergence> {
96 let session = self.view_session.as_ref()?;
97 let live = self.live_plan();
98 Some(
99 match self.store.config.views.get(session.marker.view_id()) {
100 None => ViewDivergence::deleted(session.marker.view_id(), &live),
101 Some(view) => match ViewPlan::from_toml(&view.plan) {
102 Ok(saved) => session.marker.divergence(&saved, &live),
103 Err(refusal) => ViewDivergence::unreadable(
104 session.marker.view_id(),
105 &live,
106 refusal.diagnostics().to_vec(),
107 ),
108 },
109 },
110 )
111 }
112
113 pub fn view_indicator(&self) -> Option<String> {
117 let session = self.view_session.as_ref()?;
118 Some(match self.view_divergence() {
119 Some(divergence) => crate::ui::views::indicator_line(&session.name, &divergence),
120 None => format!("view {} · (no comparison)", session.name),
121 })
122 }
123
124 pub fn active_view_feedback(&self) -> Option<ViewFeedback> {
128 let id = self.view_session.as_ref()?.marker.view_id().clone();
129 Some(self.view_feedback_for(&id, true))
130 }
131
132 pub(in crate::app) fn view_feedback_for(&self, id: &ViewId, is_active: bool) -> ViewFeedback {
138 let Some(view) = self.store.config.views.get(id) else {
139 let diagnostic = ViewDiagnostic::new(
140 crate::views::registry::ViewDiagnosticKind::UnknownView,
141 format!("no saved view with id `{id}`"),
142 );
143 return ViewFeedback::new(
144 format!("view {id} · unknown"),
145 vec![crate::ui::views::diagnostic_line(&diagnostic)],
146 );
147 };
148 let projects = Self::view_projects();
149 let mut lines = vec![format!(
150 "scope: {}",
151 scope_summary(&view.scope, view.scope.resolution(&projects))
152 )];
153 match ViewPlan::from_toml(&view.plan) {
154 Ok(plan) => {
155 lines.push(crate::ui::views::plan_summary(&plan));
156 lines.extend(
161 plan.diagnostics()
162 .iter()
163 .map(crate::ui::views::diagnostic_line),
164 );
165 if let Err(refusal) = plan.validate(self.active_board_config(), &self.store.config)
166 {
167 lines.extend(
168 refusal
169 .diagnostics()
170 .iter()
171 .map(crate::ui::views::diagnostic_line),
172 );
173 }
174 }
175 Err(refusal) => lines.extend(
176 refusal
177 .diagnostics()
178 .iter()
179 .map(crate::ui::views::diagnostic_line),
180 ),
181 }
182
183 let divergence = if is_active {
184 self.view_divergence()
185 } else {
186 None
187 };
188 let indicator = match &divergence {
189 Some(divergence) => crate::ui::views::indicator_line(&view.name, divergence),
190 None => format!("view {} · id {id}", view.name),
191 };
192 if let Some(divergence) = &divergence {
193 if divergence.in_sync() {
194 lines.push("live: in sync with the saved plan".to_string());
195 } else {
196 lines.push("live: diverged from the saved plan".to_string());
197 lines.extend(crate::ui::views::difference_lines(divergence));
198 lines.extend(
199 divergence
200 .diagnostics()
201 .iter()
202 .map(crate::ui::views::diagnostic_line),
203 );
204 }
205 }
206 lines.extend(
210 self.store
211 .config
212 .views
213 .diagnostics(&projects)
214 .into_iter()
215 .filter(|diagnostic| diagnostic.view_id() == Some(id))
216 .map(|diagnostic| crate::ui::views::diagnostic_line(&diagnostic)),
217 );
218
219 ViewFeedback::new(indicator, lines)
220 }
221
222 pub(in crate::app) fn open_views(&mut self) {
224 self.views = Some(ViewsModal::new(self.view_rows()));
225 self.refresh_views_feedback();
226 self.mode = Mode::Views;
227 }
228
229 pub(crate) fn view_rows(&self) -> Vec<ViewRow> {
231 let projects = Self::view_projects();
232 let active = self
233 .view_session
234 .as_ref()
235 .map(|session| session.marker.view_id().clone());
236 self.store
237 .config
238 .views
239 .iter()
240 .map(|view| ViewRow {
241 active: active.as_ref() == Some(&view.id),
242 id: view.id.clone(),
243 name: view.name.clone(),
244 scope: scope_summary(&view.scope, view.scope.resolution(&projects)),
245 })
246 .collect()
247 }
248
249 pub(in crate::app) fn refresh_views_rows(&mut self) {
253 let rows = self.view_rows();
254 let Some(modal) = self.views.as_mut() else {
255 return;
256 };
257 let previous = modal.selected_row().map(|row| row.id.clone());
258 modal.rows = rows;
259 if let Some(id) = previous
260 && let Some(index) = modal.rows.iter().position(|row| row.id == id)
261 {
262 modal.selected = index;
263 }
264 modal.move_selected(0);
265 }
266
267 pub(in crate::app) fn refresh_views_feedback(&mut self) {
269 let selected = self
270 .views
271 .as_ref()
272 .and_then(|modal| modal.selected_row())
273 .map(|row| (row.id.clone(), row.active));
274 let feedback = selected.map(|(id, active)| self.view_feedback_for(&id, active));
275 if let Some(modal) = self.views.as_mut() {
276 modal.feedback = feedback;
277 }
278 }
279
280 pub(in crate::app) fn note_view_outcome(&mut self, note: String) {
288 if let Some(modal) = self.views.as_mut() {
289 modal.note = Some(note.clone());
290 }
291 self.status_msg = Some(note);
292 }
293
294 pub(in crate::app) fn view_write_note(error: &ViewWriteError) -> String {
296 match error {
297 ViewWriteError::Refused(refusal) => refusal.render(),
298 ViewWriteError::Failed(error) => error.to_string(),
299 }
300 }
301
302 pub(crate) fn apply_view(&mut self, reference: &str) -> Result<Action> {
304 let path = self.view_config_path();
305 let projects = Self::view_projects();
306 let board_config = self.board_config();
307 let resolution = self.workflow_resolution();
308 let outcome = {
312 let service = ViewService::new(&mut self.store.config, &projects, &path);
313 service.apply(reference, &board_config, &self.corpus, &resolution)
314 };
315 match outcome {
316 Ok(result) => {
317 let plan = result.plan().clone();
318 let id = result.view_id().cloned();
319 let name = id
320 .as_ref()
321 .and_then(|id| self.store.config.views.get(id))
322 .map_or_else(|| reference.to_string(), |view| view.name.clone());
323 if let Some(id) = id {
328 self.view_session = Some(ViewSession {
329 visibility: plan.visibility().is_some(),
330 marker: ActiveViewMarker::new(id, plan.clone(), Some(ViewSurface::Tui)),
331 name: name.clone(),
332 });
333 }
334 self.install_live_plan(&plan);
335 self.refresh_views_rows();
336 self.refresh_views_feedback();
337 self.sync_filter_modal_feedback();
338 self.note_view_outcome(format!("Applied view '{name}'"));
339 }
340 Err(refusal) => {
341 self.sync_filter_modal_feedback();
344 self.note_view_outcome(refusal.render());
345 }
346 }
347 Ok(Action::None)
348 }
349
350 pub(in crate::app) fn install_live_plan(&mut self, plan: &ViewPlan) {
359 let selected = self
360 .board
361 .get_selected_card()
362 .map(|card| card.filename.clone());
363 self.structured = plan.filter().clone();
364 let query = plan.effective_query();
365 self.apply_filter(&query);
366 if let Some(filename) = selected {
371 self.select_card_by_filename(&filename);
372 }
373 }
374
375 pub(in crate::app) fn sync_filter_modal_feedback(&mut self) {
382 if self.filter_modal.is_none() {
383 return;
384 }
385 let active = self
386 .view_session
387 .as_ref()
388 .map(|session| session.marker.clone());
389 let feedback = if active.is_some() {
390 self.active_view_feedback()
391 } else {
392 None
393 };
394 if let Some(modal) = self.filter_modal.as_mut() {
395 modal.set_active(active, feedback);
396 }
397 }
398
399 pub(in crate::app) fn create_view_from_live(&mut self, name: String) {
406 let definition = ViewDefinition::new(name, ViewScope::repository(), self.capture_plan());
407 let path = self.view_config_path();
408 let projects = Self::view_projects();
409 let outcome = {
410 let mut service = ViewService::new(&mut self.store.config, &projects, &path);
411 service.create(definition)
412 };
413 match outcome {
414 Ok(outcome) => {
415 let audit = outcome.audit().map(|event| event.view_name().to_string());
416 let id = outcome.value().clone();
417 self.refresh_views_rows();
418 if let Some(modal) = self.views.as_mut() {
423 modal.clear_prompt();
424 if let Some(index) = modal.rows.iter().position(|row| row.id == id) {
425 modal.selected = index;
426 }
427 }
428 self.refresh_views_feedback();
429 self.note_view_outcome(format!(
430 "Created {id}{}",
431 audit.map_or_else(String::new, |name| format!(" '{name}'"))
432 ));
433 }
434 Err(error) => {
435 let note = Self::view_write_note(&error);
436 self.note_view_outcome(note);
437 }
438 }
439 }
440
441 pub(in crate::app) fn rename_view(&mut self, id: &ViewId, name: String) {
447 let path = self.view_config_path();
448 let projects = Self::view_projects();
449 let outcome = {
450 let mut service = ViewService::new(&mut self.store.config, &projects, &path);
451 match service.show(id.as_str()) {
452 Ok(detail) => {
453 let definition = ViewDefinition::new(
454 name,
455 detail.view().scope.clone(),
456 detail.plan().clone(),
457 );
458 service.update(id.as_str(), definition).map(|outcome| {
459 (
460 outcome.is_noop(),
461 outcome.value().clone(),
462 outcome.audit().map(|event| event.view_name().to_string()),
463 )
464 })
465 }
466 Err(refusal) => Err(ViewWriteError::Refused(refusal)),
467 }
468 };
469 match outcome {
470 Ok((noop, id, name)) => {
471 if let Some(modal) = self.views.as_mut() {
472 modal.clear_prompt();
473 }
474 self.refresh_views_rows();
475 self.refresh_views_feedback();
476 let word = if noop { "Unchanged" } else { "Renamed" };
477 self.note_view_outcome(format!(
478 "{word} {id}{}",
479 name.map_or_else(String::new, |name| format!(" '{name}'"))
480 ));
481 }
482 Err(error) => {
483 let note = Self::view_write_note(&error);
484 self.note_view_outcome(note);
485 }
486 }
487 }
488
489 pub(in crate::app) fn update_view_from_live(&mut self, id: &ViewId) {
493 let plan = self.capture_plan();
494 let path = self.view_config_path();
495 let projects = Self::view_projects();
496 let outcome = {
497 let mut service = ViewService::new(&mut self.store.config, &projects, &path);
498 match service.show(id.as_str()) {
499 Ok(detail) => {
500 let definition = ViewDefinition::new(
501 detail.view().name.clone(),
502 detail.view().scope.clone(),
503 plan,
504 );
505 service.update(id.as_str(), definition).map(|outcome| {
506 (
507 outcome.is_noop(),
508 outcome.value().clone(),
509 outcome.audit().map(|event| event.view_name().to_string()),
510 )
511 })
512 }
513 Err(refusal) => Err(ViewWriteError::Refused(refusal)),
514 }
515 };
516 match outcome {
517 Ok((noop, id, name)) => {
518 self.refresh_views_rows();
519 self.refresh_views_feedback();
520 let word = if noop { "Unchanged" } else { "Updated" };
521 self.note_view_outcome(format!(
522 "{word} {id}{}",
523 name.map_or_else(String::new, |name| format!(" '{name}'"))
524 ));
525 }
526 Err(error) => {
527 let note = Self::view_write_note(&error);
528 self.note_view_outcome(note);
529 }
530 }
531 }
532
533 pub(in crate::app) fn duplicate_view(&mut self, id: &ViewId) {
535 let path = self.view_config_path();
536 let projects = Self::view_projects();
537 let outcome = {
538 let mut service = ViewService::new(&mut self.store.config, &projects, &path);
539 service.duplicate(id.as_str(), None)
540 };
541 match outcome {
542 Ok(outcome) => {
543 let copy = outcome.value().clone();
544 self.refresh_views_rows();
545 if let Some(modal) = self.views.as_mut()
546 && let Some(index) = modal.rows.iter().position(|row| row.id == copy)
547 {
548 modal.selected = index;
549 }
550 self.refresh_views_feedback();
551 self.note_view_outcome(format!("Duplicated {id} -> {copy}"));
552 }
553 Err(error) => {
554 let note = Self::view_write_note(&error);
555 self.note_view_outcome(note);
556 }
557 }
558 }
559
560 pub(in crate::app) fn delete_view(&mut self, id: &ViewId) {
568 let path = self.view_config_path();
569 let projects = Self::view_projects();
570 let outcome = {
571 let mut service = ViewService::new(&mut self.store.config, &projects, &path);
572 service.delete(id.as_str(), DeleteConfirmed::confirmed())
573 };
574 match outcome {
575 Ok(outcome) => {
576 let removed = outcome.value();
577 let name = removed.view().name.clone();
578 let cleared = self.active_view_id() == Some(id);
582 if cleared {
583 self.view_session = None;
584 }
585 self.refresh_views_rows();
586 self.refresh_views_feedback();
587 self.sync_filter_modal_feedback();
588 self.note_view_outcome(if cleared {
589 format!("Deleted '{name}' (definition only; active marker cleared)")
590 } else {
591 format!("Deleted '{name}' (definition only)")
592 });
593 }
594 Err(error) => {
595 let note = Self::view_write_note(&error);
596 self.note_view_outcome(note);
597 }
598 }
599 }
600
601 #[allow(clippy::too_many_lines)]
608 pub(in crate::app) fn handle_views_key(&mut self, key: KeyEvent) -> Result<Action> {
609 if self.views.is_none() {
610 self.mode = Mode::Board;
611 return Ok(Action::None);
612 }
613 let prompt = self.views.as_ref().and_then(|modal| modal.prompt.clone());
614
615 match prompt {
616 Some(ViewsPrompt::Create(buffer)) => match key.code {
617 KeyCode::Esc => {
618 if let Some(modal) = self.views.as_mut() {
619 modal.clear_prompt();
620 }
621 }
622 KeyCode::Enter => self.create_view_from_live(buffer),
623 KeyCode::Backspace => {
624 if let Some(modal) = self.views.as_mut()
625 && let Some(ViewsPrompt::Create(buffer)) = modal.prompt.as_mut()
626 {
627 buffer.pop();
628 }
629 }
630 KeyCode::Char(c) => {
631 if let Some(modal) = self.views.as_mut()
632 && let Some(ViewsPrompt::Create(buffer)) = modal.prompt.as_mut()
633 {
634 buffer.push(c);
635 }
636 }
637 _ => {}
638 },
639 Some(ViewsPrompt::Rename { id, buffer }) => match key.code {
640 KeyCode::Esc => {
641 if let Some(modal) = self.views.as_mut() {
642 modal.clear_prompt();
643 }
644 }
645 KeyCode::Enter => self.rename_view(&id, buffer),
646 KeyCode::Backspace => {
647 if let Some(modal) = self.views.as_mut()
648 && let Some(ViewsPrompt::Rename { buffer, .. }) = modal.prompt.as_mut()
649 {
650 buffer.pop();
651 }
652 }
653 KeyCode::Char(c) => {
654 if let Some(modal) = self.views.as_mut()
655 && let Some(ViewsPrompt::Rename { buffer, .. }) = modal.prompt.as_mut()
656 {
657 buffer.push(c);
658 }
659 }
660 _ => {}
661 },
662 Some(ViewsPrompt::ConfirmDelete { id, .. }) => {
665 if let Some(modal) = self.views.as_mut() {
666 modal.clear_prompt();
667 }
668 match key.code {
669 KeyCode::Char('y') => self.delete_view(&id),
670 KeyCode::Esc => self.refresh_views_feedback(),
671 _ => {}
672 }
673 }
674 None => match key.code {
675 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
676 self.views = None;
677 self.mode = Mode::Board;
678 }
679 KeyCode::Up | KeyCode::Char('k') => {
680 if let Some(modal) = self.views.as_mut() {
681 modal.move_selected(-1);
682 modal.note = None;
686 }
687 self.refresh_views_feedback();
688 }
689 KeyCode::Down | KeyCode::Char('j') => {
690 if let Some(modal) = self.views.as_mut() {
691 modal.move_selected(1);
692 modal.note = None;
693 }
694 self.refresh_views_feedback();
695 }
696 KeyCode::Enter => {
697 if let Some(id) = self
698 .views
699 .as_ref()
700 .and_then(|modal| modal.selected_row())
701 .map(|row| row.id.clone())
702 {
703 self.apply_view(id.as_str())?;
704 }
705 }
706 KeyCode::Char('n') => {
707 if let Some(modal) = self.views.as_mut() {
708 modal.note = None;
709 modal.prompt = Some(ViewsPrompt::Create(String::new()));
710 }
711 }
712 KeyCode::Char('e') => {
713 let selected = self
714 .views
715 .as_ref()
716 .and_then(|modal| modal.selected_row())
717 .map(|row| (row.id.clone(), row.name.clone()));
718 if let Some((id, name)) = selected
719 && let Some(modal) = self.views.as_mut()
720 {
721 modal.note = None;
722 modal.prompt = Some(ViewsPrompt::Rename { id, buffer: name });
723 }
724 }
725 KeyCode::Char('u') => {
726 let selected = self
727 .views
728 .as_ref()
729 .and_then(|modal| modal.selected_row())
730 .map(|row| row.id.clone());
731 if let Some(id) = selected {
732 self.update_view_from_live(&id);
733 }
734 }
735 KeyCode::Char('d') => {
736 let selected = self
737 .views
738 .as_ref()
739 .and_then(|modal| modal.selected_row())
740 .map(|row| row.id.clone());
741 if let Some(id) = selected {
742 self.duplicate_view(&id);
743 }
744 }
745 KeyCode::Char('x') | KeyCode::Char('X') => {
746 let selected = self
747 .views
748 .as_ref()
749 .and_then(|modal| modal.selected_row())
750 .map(|row| (row.id.clone(), row.name.clone()));
751 if let Some((id, name)) = selected
752 && let Some(modal) = self.views.as_mut()
753 {
754 modal.note = None;
755 modal.prompt = Some(ViewsPrompt::ConfirmDelete { id, name });
756 }
757 }
758 _ => {}
759 },
760 }
761 Ok(Action::None)
762 }
763
764 pub(in crate::app) fn handle_views_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
773 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
774 return Ok(Action::None);
775 }
776 if self.views.as_ref().is_some_and(ViewsModal::is_prompting) {
777 return Ok(Action::None);
778 }
779 let area = self.frame_size.get();
780 let hit = self.views.as_ref().and_then(|modal| {
781 modal
782 .row_hit_regions(area)
783 .into_iter()
784 .find(|(rect, _)| rect.contains(Position::new(mouse.column, mouse.row)))
785 .map(|(_, index)| index)
786 });
787 let Some(index) = hit else {
788 return Ok(Action::None);
789 };
790 let selected = {
791 let Some(modal) = self.views.as_mut() else {
792 return Ok(Action::None);
793 };
794 modal.move_selected_to(index);
795 modal.selected_row().map(|row| row.id.clone())
796 };
797 if let Some(id) = selected {
798 self.apply_view(id.as_str())?;
799 }
800 Ok(Action::None)
801 }
802}