Skip to main content

tix/ui/
views.rs

1//! Saved-views modal: list, apply, and manage the registry (WP05, T024–T027;
2//! mission `saved-issue-views-01M3C926`).
3//!
4//! This module is a **pure view + model**, exactly as `filter_modal.rs` and
5//! `settings.rs` are: it renders rows, holds a cursor and an inline prompt, and
6//! never reads a key, touches `tix.toml`, or interprets a query. `app.rs` routes
7//! the keys and calls the shared [`crate::views::service::ViewService`] — the
8//! same service `tix view` calls — so every rule (stable ids, scope precedence,
9//! plan validation, definition-only delete) stays owned once, by WP01–WP03.
10//!
11//! # What this surface deliberately does not do
12//!
13//! - **no second grammar** (C-002/C-004): a view created here is built from the
14//!   *live* [`StructuredFilter`](crate::search::StructuredFilter) and text query
15//!   through [`ViewPlan::from_parts`], the same adapter the CLI uses;
16//! - **no writes to issues** (C-005): deleting a view removes the saved
17//!   definition and nothing else;
18//! - **no reconciliation** (C-006): divergence is *reported* by the feedback
19//!   panel, never applied.
20//!
21//! # Display parity
22//!
23//! The feedback text is derived by `app.rs` from the shared domain types and
24//! rendered verbatim here, so the widget owns no comparison. The spellings it
25//! renders — `scope_label`, the diagnostic line, the plan summary — mirror the
26//! ones `tix view show`/`tix view status` print; the CLI's private helpers
27//! (`crate::commands::views::scope_label`, `render_diagnostic`, `plan_lines`) are
28//! the reference, and the tests below pin them.
29
30use ratatui::Frame;
31use ratatui::layout::Rect;
32use ratatui::style::{Color, Modifier, Style};
33use ratatui::text::{Line, Span};
34use ratatui::widgets::{Block, Borders, Clear, Paragraph};
35
36use crate::keybindings::{EffectiveBindings, InputContext, KeyBindings, TuiAction};
37use crate::views::divergence::ViewDivergence;
38use crate::views::plan::ViewPlan;
39use crate::views::registry::{ScopeResolution, ViewDiagnostic, ViewId, ViewRefusal, ViewScope};
40
41use super::modal::modal_rect;
42
43/// The rendered saved-view feedback: one indicator line plus ordered detail
44/// lines (T024/T026).
45///
46/// The **text** is derived once, in `app.rs`, from the shared domain types and
47/// then rendered by both the filter modal and the views modal. A widget that
48/// derives its own text would eventually describe the same record differently
49/// from the other one, which is the drift this split exists to prevent.
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct ViewFeedback {
52    /// The compact one-line indicator, e.g. `view Active · diverged (plan.sort)`.
53    pub indicator: String,
54    /// Ordered detail: the scope, the plan, the differences, the diagnostics.
55    pub lines: Vec<String>,
56}
57
58impl ViewFeedback {
59    /// Feedback for a saved view: its indicator and its detail lines.
60    #[must_use]
61    pub fn new(indicator: impl Into<String>, lines: Vec<String>) -> Self {
62        Self {
63            indicator: indicator.into(),
64            lines,
65        }
66    }
67}
68
69/// One saved view as the modal lists it (T024).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ViewRow {
72    /// The stable id — the identity a mutation and the active marker use. Never
73    /// the name (C-002).
74    pub id: ViewId,
75    /// The display label. Renameable; never the identity.
76    pub name: String,
77    /// `scope_label (resolution)`, already rendered by the app.
78    pub scope: String,
79    /// Whether the live board is associated with this view (FR-004).
80    pub active: bool,
81}
82
83/// The inline prompt the modal is showing, if any (T027).
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ViewsPrompt {
86    /// Naming a new view, which is created from the live plan.
87    Create(String),
88    /// Renaming the view with this stable id.
89    Rename { id: ViewId, buffer: String },
90    /// Confirming the definition-only delete of the view with this id.
91    ConfirmDelete { id: ViewId, name: String },
92}
93
94/// The saved-views modal's own state: rows, cursor, prompt, feedback.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct ViewsModal {
97    /// One row per stored view, in declaration order (registry order).
98    pub rows: Vec<ViewRow>,
99    /// The cursor over `rows`.
100    pub selected: usize,
101    /// The active inline prompt, or `None` while browsing the list.
102    pub prompt: Option<ViewsPrompt>,
103    /// Feedback for the row under the cursor, refreshed by the app (T026).
104    pub feedback: Option<ViewFeedback>,
105    /// One line about the last operation: a refusal, or what was written.
106    pub note: Option<String>,
107}
108
109impl ViewsModal {
110    /// A modal over `rows`, with the cursor on the active view when one is
111    /// marked and on the first row otherwise — so opening the view list shows
112    /// the view the board is currently associated with.
113    #[must_use]
114    pub fn new(rows: Vec<ViewRow>) -> Self {
115        let selected = rows.iter().position(|row| row.active).unwrap_or(0);
116        Self {
117            rows,
118            selected,
119            prompt: None,
120            feedback: None,
121            note: None,
122        }
123    }
124
125    /// The row under the cursor, when the list is not empty.
126    #[must_use]
127    pub fn selected_row(&self) -> Option<&ViewRow> {
128        self.rows.get(self.selected)
129    }
130
131    /// Move the cursor by `delta`, clamped to the list (no wrap: the views modal
132    /// is a menu, and a menu that wraps hides how many entries there are).
133    pub fn move_selected(&mut self, delta: isize) {
134        if self.rows.is_empty() {
135            self.selected = 0;
136            return;
137        }
138        let last = self.rows.len() - 1;
139        let next = if delta < 0 {
140            self.selected.saturating_sub(delta.unsigned_abs())
141        } else {
142            self.selected.saturating_add(delta as usize)
143        };
144        self.selected = next.min(last);
145    }
146
147    /// Jump the cursor straight to `row` (a row click, D6), clamped to the same
148    /// range the arrow keys navigate.
149    pub fn move_selected_to(&mut self, row: usize) {
150        if self.rows.is_empty() {
151            self.selected = 0;
152            return;
153        }
154        self.selected = row.min(self.rows.len() - 1);
155    }
156
157    /// Whether an inline prompt is open.
158    #[must_use]
159    pub const fn is_prompting(&self) -> bool {
160        self.prompt.is_some()
161    }
162
163    /// Drop the prompt (a cancel, or after a completed operation).
164    pub fn clear_prompt(&mut self) {
165        self.prompt = None;
166    }
167
168    /// The first row index the modal draws for a body `inner_h` rows tall.
169    fn window_start(&self, inner_h: usize) -> usize {
170        let row_budget = inner_h.min(self.rows.len());
171        if self.rows.len() > row_budget {
172            (self.selected + 1).saturating_sub(row_budget)
173        } else {
174            0
175        }
176    }
177
178    /// The clickable rect of every **visible** row, paired with that row's index
179    /// in [`Self::rows`] (D6).
180    ///
181    /// The widget owns its own layout, so the app asks it where the rows are
182    /// rather than re-deriving the windowing and drifting from it — a click that
183    /// selected the wrong view would be a silent wrong action, which is exactly
184    /// what the region indirection exists to prevent.
185    #[must_use]
186    pub fn row_hit_regions(&self, area: Rect) -> Vec<(Rect, usize)> {
187        let rect = modal_rect(area);
188        let inner_h = rect.height.saturating_sub(2) as usize;
189        let start = self.window_start(inner_h);
190        let visible = inner_h.min(self.rows.len());
191        (0..visible)
192            .map(|offset| {
193                (
194                    Rect {
195                        x: rect.x + 1,
196                        y: rect.y + 1 + offset as u16,
197                        width: rect.width.saturating_sub(2),
198                        height: 1,
199                    },
200                    start + offset,
201                )
202            })
203            .collect()
204    }
205
206    /// Render the modal with the built-in keybindings (tests, and any caller that
207    /// has no `EffectiveBindings` at hand).
208    pub fn render(&self, f: &mut Frame, area: Rect) {
209        let bindings =
210            KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
211        self.render_with_bindings(f, area, &bindings, InputContext::Views);
212    }
213
214    #[allow(clippy::too_many_lines)]
215    pub fn render_with_bindings(
216        &self,
217        f: &mut Frame,
218        area: Rect,
219        bindings: &EffectiveBindings,
220        context: InputContext,
221    ) {
222        let rect = modal_rect(area);
223        let inner_h = rect.height.saturating_sub(2) as usize;
224        let mut lines: Vec<Line<'static>> = Vec::new();
225
226        // The list is windowed (not truncated) around the cursor, so a registry
227        // longer than the modal can still be walked: the cursor is always on
228        // screen.
229        let row_budget = inner_h.min(self.rows.len());
230        let start = self.window_start(inner_h);
231        for (offset, row) in self.rows[start..].iter().take(row_budget).enumerate() {
232            let is_cursor = start + offset == self.selected;
233            let marker = if row.active { "*" } else { " " };
234            let cursor = if is_cursor { "> " } else { "  " };
235            let style = if is_cursor {
236                Style::default()
237                    .fg(Color::Yellow)
238                    .add_modifier(Modifier::BOLD)
239            } else {
240                Style::default().fg(Color::White)
241            };
242            lines.push(Line::from(vec![
243                Span::styled(cursor, style),
244                Span::styled(
245                    format!("{marker} {}", row.name),
246                    if row.active {
247                        style.fg(Color::Cyan).add_modifier(Modifier::BOLD)
248                    } else {
249                        style
250                    },
251                ),
252                Span::styled(
253                    format!("  [{}]", row.scope),
254                    Style::default().fg(Color::DarkGray),
255                ),
256            ]));
257        }
258        if self.rows.is_empty() {
259            lines.push(Line::from(Span::styled(
260                "  (no saved views)",
261                Style::default().fg(Color::DarkGray),
262            )));
263        }
264
265        let mut budget = inner_h.saturating_sub(lines.len());
266        let mut push = |line: Line<'static>, budget: &mut usize| {
267            if *budget > 0 {
268                lines.push(line);
269                *budget -= 1;
270            }
271        };
272
273        // The last operation's outcome first: it is the reason the prompt is
274        // still open when a name was refused, and the reason the list changed
275        // when one was accepted.
276        if let Some(note) = &self.note {
277            push(
278                Line::from(Span::styled(
279                    note.clone(),
280                    Style::default().fg(Color::Yellow),
281                )),
282                &mut budget,
283            );
284        }
285
286        match &self.prompt {
287            Some(ViewsPrompt::Create(buffer)) => push(
288                Line::from(Span::styled(
289                    format!("New view name: {buffer}_"),
290                    Style::default().fg(Color::Cyan),
291                )),
292                &mut budget,
293            ),
294            Some(ViewsPrompt::Rename { buffer, .. }) => push(
295                Line::from(Span::styled(
296                    format!("Rename to: {buffer}_"),
297                    Style::default().fg(Color::Cyan),
298                )),
299                &mut budget,
300            ),
301            Some(ViewsPrompt::ConfirmDelete { name, .. }) => push(
302                Line::from(Span::styled(
303                    format!("Delete view '{name}' (definition only)? [y/N]"),
304                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
305                )),
306                &mut budget,
307            ),
308            None => {
309                if let Some(feedback) = &self.feedback {
310                    // Indicator first: the same line the board shows, so the two
311                    // cannot say different things about the same state.
312                    push(
313                        Line::from(Span::styled(
314                            feedback.indicator.clone(),
315                            Style::default().fg(Color::Green),
316                        )),
317                        &mut budget,
318                    );
319                    for line in &feedback.lines {
320                        push(
321                            Line::from(Span::styled(
322                                line.clone(),
323                                Style::default().fg(Color::DarkGray),
324                            )),
325                            &mut budget,
326                        );
327                    }
328                }
329            }
330        }
331
332        if budget > 0 {
333            // The last row is the key legend: a binding nobody can find is a
334            // binding nobody uses.
335            let footer = match &self.prompt {
336                Some(ViewsPrompt::ConfirmDelete { .. }) => format!(
337                    "{}: delete · {}: cancel",
338                    bindings.label(InputContext::ConfirmDelete, TuiAction::Confirm),
339                    bindings.label(InputContext::ConfirmDelete, TuiAction::Cancel),
340                ),
341                Some(_) => format!(
342                    "{}: save · {}: cancel",
343                    bindings.label(context, TuiAction::Submit),
344                    bindings.label(context, TuiAction::Cancel),
345                ),
346                None => format!(
347                    "{}: apply · {}: new · {}: rename · {}: update from live · \
348                     {}: duplicate · {}: delete · {}: close",
349                    bindings.label(context, TuiAction::ApplyView),
350                    bindings.label(context, TuiAction::NewView),
351                    bindings.label(context, TuiAction::RenameView),
352                    bindings.label(context, TuiAction::UpdateView),
353                    bindings.label(context, TuiAction::DuplicateView),
354                    bindings.label(context, TuiAction::DeleteView),
355                    bindings.label(context, TuiAction::Close),
356                ),
357            };
358            lines.push(Line::from(Span::styled(
359                footer,
360                Style::default().fg(Color::DarkGray),
361            )));
362        }
363
364        let block = Block::default()
365            .borders(Borders::ALL)
366            .title(format!(" Saved views ({}) ", self.rows.len()))
367            .style(Style::default().fg(Color::Cyan));
368        let para = Paragraph::new(lines).block(block);
369        f.render_widget(Clear, rect);
370        f.render_widget(para, rect);
371    }
372}
373
374/// One diagnostic, rendered through the **shared** refusal renderer, so the TUI
375/// spells a diagnostic exactly as `tix view show` does (`kind: message`).
376#[must_use]
377pub fn diagnostic_line(diagnostic: &ViewDiagnostic) -> String {
378    ViewRefusal::from_diagnostic(diagnostic.clone()).render()
379}
380
381/// The scope family and its metadata as one token.
382///
383/// The spellings mirror `tix view show`'s (`crate::commands::views::scope_label`)
384/// so the two surfaces describe one record identically.
385#[must_use]
386pub fn scope_label(scope: &ViewScope) -> String {
387    match scope.kind {
388        crate::views::registry::ViewScopeKind::Repository => "repository".to_string(),
389        crate::views::registry::ViewScopeKind::Project => {
390            format!("project {}", scope.project_id.as_deref().unwrap_or(""))
391        }
392        crate::views::registry::ViewScopeKind::UserShare => {
393            let mut parts = vec!["user".to_string()];
394            if let Some(owner) = scope.owner.as_deref() {
395                parts.push(format!("owner={owner}"));
396            }
397            if !scope.shared_with.is_empty() {
398                parts.push(format!("shared_with={}", scope.shared_with.join(",")));
399            }
400            parts.join(" ")
401        }
402    }
403}
404
405/// `repository (resolved)` — the scope family plus its derived resolution, the
406/// two facts T026 asks the TUI to render.
407#[must_use]
408pub fn scope_summary(scope: &ViewScope, resolution: ScopeResolution) -> String {
409    format!("{} ({resolution})", scope_label(scope))
410}
411
412/// The plan as one line of `key: value` parts, in the vocabulary
413/// `tix view show` prints one-per-line.
414#[must_use]
415pub fn plan_summary(plan: &ViewPlan) -> String {
416    let mut parts = vec![format!("filter: {}", plan.filter())];
417    if let Some(query) = plan.text_query() {
418        parts.push(format!("query: {query}"));
419    }
420    if let Some(sort) = plan.sort() {
421        parts.push(format!("sort: {sort}"));
422    }
423    if let Some(group) = plan.group() {
424        parts.push(format!("group: {group}"));
425    }
426    if let Some(visibility) = plan.visibility() {
427        parts.push(format!(
428            "hide-columns: {}",
429            visibility.hidden_columns().join(",")
430        ));
431        parts.push(format!(
432            "show-mismatch: {}",
433            visibility
434                .show_mismatch()
435                .map_or_else(|| "inherit".to_string(), |value| value.to_string())
436        ));
437    }
438    parts.join(" · ")
439}
440
441/// One line per divergent field, `saved=… live=…` with `(unset)` for an unset
442/// value — the same shape `tix view status` prints.
443#[must_use]
444pub fn difference_lines(divergence: &ViewDivergence) -> Vec<String> {
445    divergence
446        .differences()
447        .iter()
448        .map(|difference| {
449            format!(
450                "{}: saved={} live={}",
451                difference.field(),
452                difference.saved().unwrap_or("(unset)"),
453                difference.live().unwrap_or("(unset)"),
454            )
455        })
456        .collect()
457}
458
459/// The compact indicator: `view Foo · in sync` or
460/// `view Foo · diverged (plan.sort, plan.group)`.
461///
462/// Deliberately one line: the board renders it in a single row, and a truncating
463/// surface must be able to tell "in sync" from "diverged" from the prefix. The
464/// full differences follow in the feedback panel.
465#[must_use]
466pub fn indicator_line(name: &str, divergence: &ViewDivergence) -> String {
467    if !divergence.in_sync() {
468        let fields: Vec<&str> = divergence
469            .differences()
470            .iter()
471            .map(|difference| difference.field())
472            .collect();
473        let mut summary = if fields.is_empty() {
474            "diverged".to_string()
475        } else {
476            format!("diverged ({})", fields.join(", "))
477        };
478        if !divergence.diagnostics().is_empty() {
479            summary.push_str(" +diagnostics");
480        }
481        return format!("view {name} · {summary}");
482    }
483    format!("view {name} · in sync")
484}
485
486#[cfg(test)]
487mod tests {
488    #[allow(clippy::wildcard_imports)]
489    use super::*;
490    use crate::search::StructuredFilter;
491    use crate::views::registry::ViewDiagnosticKind;
492    use ratatui::Terminal;
493    use ratatui::backend::TestBackend;
494
495    fn row(id: &str, name: &str, active: bool) -> ViewRow {
496        ViewRow {
497            id: ViewId::new(id).unwrap(),
498            name: name.to_string(),
499            scope: "repository (resolved)".to_string(),
500            active,
501        }
502    }
503
504    #[test]
505    fn the_cursor_opens_on_the_active_view() {
506        let modal = ViewsModal::new(vec![row("v-1", "One", false), row("v-2", "Two", true)]);
507        assert_eq!(modal.selected, 1);
508        assert_eq!(modal.selected_row().unwrap().name, "Two");
509    }
510
511    #[test]
512    fn the_cursor_clamps_at_both_ends() {
513        let mut modal = ViewsModal::new(vec![row("v-1", "One", false), row("v-2", "Two", false)]);
514        modal.move_selected(-1);
515        assert_eq!(modal.selected, 0);
516        modal.move_selected(5);
517        assert_eq!(modal.selected, 1);
518        modal.move_selected_to(99);
519        assert_eq!(modal.selected, 1);
520        modal.move_selected_to(0);
521        assert_eq!(modal.selected, 0);
522    }
523
524    #[test]
525    fn an_empty_registry_renders_and_keeps_the_cursor_at_zero() {
526        let mut modal = ViewsModal::new(Vec::new());
527        modal.move_selected(3);
528        assert_eq!(modal.selected, 0);
529        assert!(modal.selected_row().is_none());
530        let backend = TestBackend::new(80, 24);
531        let mut terminal = Terminal::new(backend).unwrap();
532        terminal.draw(|f| modal.render(f, f.area())).unwrap();
533    }
534
535    #[test]
536    fn scope_labels_name_the_family_and_its_metadata() {
537        assert_eq!(scope_label(&ViewScope::repository()), "repository");
538        assert_eq!(
539            scope_label(&ViewScope::project("p-platform")),
540            "project p-platform"
541        );
542        assert_eq!(
543            scope_label(&ViewScope::user_share(
544                Some("alice".to_string()),
545                vec!["bob".to_string()]
546            )),
547            "user owner=alice shared_with=bob"
548        );
549        assert_eq!(
550            scope_summary(&ViewScope::repository(), ScopeResolution::Resolved),
551            "repository (resolved)"
552        );
553    }
554
555    #[test]
556    fn a_diagnostic_line_is_the_shared_refusal_spelling() {
557        let diagnostic = ViewDiagnostic::new(ViewDiagnosticKind::UnknownMetadata, "kept")
558            .with_field("extensions.colour");
559        // `ViewRefusal::render` goes through the shared
560        // `error::render_diagnostics(id, pairs)`, which is the *rendered* form —
561        // level, id, message — exactly as `Diagnostic::error(...).render()` spells
562        // it. The mission was written against the plain `kind: message` form that
563        // the one-argument renderer produced; the id is part of the shared
564        // spelling now, and the modal shows one line of it.
565        assert_eq!(
566            diagnostic_line(&diagnostic),
567            "error[E-tix-views]: unknown_metadata: kept"
568        );
569    }
570
571    #[test]
572    fn an_in_sync_indicator_names_the_view_only() {
573        let plan = ViewPlan::from_parts(StructuredFilter::default(), "sort:due");
574        let divergence = ViewDivergence::between(&plan, &plan);
575        assert_eq!(
576            indicator_line("Active", &divergence),
577            "view Active · in sync"
578        );
579        assert_eq!(
580            difference_lines(&divergence),
581            [] as [std::string::String; 0]
582        );
583    }
584
585    #[test]
586    fn a_diverged_indicator_names_the_divergent_fields() {
587        let saved = ViewPlan::from_parts(StructuredFilter::default(), "sort:due");
588        let live = ViewPlan::from_parts(StructuredFilter::default(), "sort:created");
589        let divergence = ViewDivergence::between(&saved, &live);
590        assert_eq!(
591            indicator_line("Active", &divergence),
592            "view Active · diverged (plan.sort)"
593        );
594        assert_eq!(
595            difference_lines(&divergence),
596            vec!["plan.sort: saved=due live=created".to_string()]
597        );
598    }
599
600    #[test]
601    fn a_missing_marker_indicator_says_so_rather_than_in_sync() {
602        let live = ViewPlan::from_parts(StructuredFilter::default(), "sort:due");
603        let divergence = ViewDivergence::missing_marker(&live);
604        assert!(!divergence.in_sync());
605        assert_eq!(
606            indicator_line("Active", &divergence),
607            "view Active · diverged +diagnostics"
608        );
609    }
610
611    #[test]
612    fn a_plan_summary_lists_the_set_fields_in_the_cli_vocabulary() {
613        let plan = ViewPlan::from_parts(StructuredFilter::default(), "sort:due group:cycle");
614        assert_eq!(
615            plan_summary(&plan),
616            "filter: (no active criteria) · sort: due · group: cycle"
617        );
618    }
619
620    #[test]
621    fn render_smoke_at_several_sizes() {
622        for (w, h) in [(120, 40), (80, 24), (20, 5)] {
623            let mut modal =
624                ViewsModal::new(vec![row("v-1", "One", true), row("v-2", "Two", false)]);
625            modal.feedback = Some(ViewFeedback::new(
626                "view One · in sync",
627                vec!["scope: repository (resolved)".to_string()],
628            ));
629            modal.note = Some("Saved v-1".to_string());
630            let backend = TestBackend::new(w, h);
631            let mut terminal = Terminal::new(backend).unwrap();
632            terminal.draw(|f| modal.render(f, f.area())).unwrap();
633        }
634    }
635}