1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct ViewFeedback {
52 pub indicator: String,
54 pub lines: Vec<String>,
56}
57
58impl ViewFeedback {
59 #[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#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ViewRow {
72 pub id: ViewId,
75 pub name: String,
77 pub scope: String,
79 pub active: bool,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ViewsPrompt {
86 Create(String),
88 Rename { id: ViewId, buffer: String },
90 ConfirmDelete { id: ViewId, name: String },
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct ViewsModal {
97 pub rows: Vec<ViewRow>,
99 pub selected: usize,
101 pub prompt: Option<ViewsPrompt>,
103 pub feedback: Option<ViewFeedback>,
105 pub note: Option<String>,
107}
108
109impl ViewsModal {
110 #[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 #[must_use]
127 pub fn selected_row(&self) -> Option<&ViewRow> {
128 self.rows.get(self.selected)
129 }
130
131 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 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 #[must_use]
159 pub const fn is_prompting(&self) -> bool {
160 self.prompt.is_some()
161 }
162
163 pub fn clear_prompt(&mut self) {
165 self.prompt = None;
166 }
167
168 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 #[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 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 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 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 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 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#[must_use]
377pub fn diagnostic_line(diagnostic: &ViewDiagnostic) -> String {
378 ViewRefusal::from_diagnostic(diagnostic.clone()).render()
379}
380
381#[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#[must_use]
408pub fn scope_summary(scope: &ViewScope, resolution: ScopeResolution) -> String {
409 format!("{} ({resolution})", scope_label(scope))
410}
411
412#[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#[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#[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 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}