1use crate::app::jobs::{Answer, Job};
5use crate::app::modals::filter_modal::FilterStatement;
6use crate::table::DataTableState;
7use crate::view::SavedView;
8use crate::widgets::view_modal::{FormFocus, ViewModalMode, ViewRow};
9use crate::{
10 App, QueryRun, Replayed, RunOrigin, active_query_settings, view, view_settings_of, widgets,
11};
12use color_eyre::Result;
13use polars::prelude::DataFrame;
14
15pub struct SavedViews {
17 pub(crate) manager: crate::view::Views,
18 pub(crate) active_id: Option<String>, }
20
21#[derive(Debug, Clone)]
23pub(crate) enum Applying {
24 Asked,
26 Matched(view::MatchReason),
29 Restored(Option<Box<crate::table::DrillPlace>>),
32}
33
34pub(crate) fn view_failure(applying: &Applying, message: &str) -> String {
37 if matches!(applying, Applying::Restored(_)) {
38 format!("Reopened, but the query, filters and sort could not be applied again: {message}")
39 } else {
40 format!("Error applying view: {message}")
41 }
42}
43
44impl SavedViews {
45 pub(crate) fn reset_for_dataset(&mut self) {
47 self.active_id = None;
48 }
49}
50
51impl App {
52 pub(crate) fn open_view_list(&mut self) {
54 if self.view_dataset().is_none() {
55 return;
56 }
57 self.view_modal.table_state.select(Some(0));
58 self.refresh_view_list();
59 self.view_modal.mode = ViewModalMode::List;
60 self.open_overlay(crate::Overlay::View);
61 }
62
63 pub(crate) fn refresh_view_list(&mut self) {
66 let (Some(state), Some(dataset)) = (&self.data_table_state, self.view_dataset()) else {
67 return;
68 };
69 let rows: Vec<ViewRow> = self
70 .views
71 .manager
72 .find_relevant_views(dataset, state.source_schema())
73 .into_iter()
74 .map(|(view, score)| {
75 let reason = view::match_reason(&view, dataset, state.source_schema());
76 ViewRow {
77 view,
78 score,
79 reason,
80 }
81 })
82 .collect();
83 self.view_modal.broken_views = self.views.manager.broken_views.clone();
84 let selected = self.view_modal.table_state.selected().unwrap_or(0);
85 self.view_modal.table_state.select(if rows.is_empty() {
86 None
87 } else {
88 Some(selected.min(rows.len() - 1))
89 });
90 self.view_modal.rows = rows;
91 }
92
93 pub(crate) fn open_save_view_form(&mut self) {
97 self.view_modal
98 .enter_create_mode(self.display.history_limit, &self.theme);
99
100 let query = self.data_table_state.as_ref().and_then(|state| {
101 let (query, sql_query, fuzzy_query) = active_query_settings(
102 state.get_active_query(),
103 state.get_active_sql_query(),
104 state.get_active_fuzzy_query(),
105 );
106 sql_query.or(fuzzy_query).or(query)
107 });
108 self.view_modal.name_input.suggest(
109 self.views
110 .manager
111 .suggest_name(self.path.as_deref(), query.as_deref()),
112 );
113
114 if let Some(path) = self.path.as_ref().filter(|_| !self.reads_stdin()) {
116 let absolute_path = view::exact_location(path);
119 self.view_modal
120 .exact_path_input
121 .suggest(absolute_path.to_string_lossy());
122 if let Some(relative) = view::relative_location(path) {
123 self.view_modal.relative_path_input.suggest(relative);
124 }
125
126 if let Some(parent) = absolute_path.parent()
130 && let Some(parent_str) = parent.to_str()
131 && !parent_str.is_empty()
132 && let Some(ext) = absolute_path.extension()
133 {
134 let separator = if crate::cloud::source::is_remote_url(path) {
135 '/'
136 } else {
137 std::path::MAIN_SEPARATOR
138 };
139 self.view_modal.path_pattern_input.suggest(format!(
140 "{}{separator}*.{}",
141 parent_str.trim_end_matches(separator),
142 ext.to_string_lossy()
143 ));
144 }
145
146 if let Some(filename) = path.file_name()
149 && let Some(filename_str) = filename.to_str()
150 {
151 use regex::Regex;
152 let pattern = match Regex::new(r"\d+") {
153 Ok(re) => re.replace_all(filename_str, "*").to_string(),
154 Err(_) => filename_str.to_string(),
155 };
156 self.view_modal.filename_pattern_input.suggest(pattern);
157 }
158 }
159
160 self.view_modal.table = self.view_table().map(str::to_string);
161
162 if let Some(ref state) = self.data_table_state
165 && !state.source_schema().is_empty()
166 {
167 self.view_modal.schema_match_enabled = true;
168 }
169 }
170
171 pub(crate) fn save_view_form(&mut self) {
174 self.view_modal.name_error = None;
175 let name = self.view_modal.name_input.value().trim().to_string();
176 if name.is_empty() {
177 self.view_modal.name_error = Some("name is required".to_string());
178 self.view_modal.form_focus = FormFocus::Name;
179 return;
180 }
181 let renaming_to_taken = match &self.view_modal.editing_view_id {
182 None => self.views.manager.view_exists(&name),
183 Some(id) => self
184 .views
185 .manager
186 .get_view_by_name(&name)
187 .is_some_and(|other| other.id != *id),
188 };
189 if renaming_to_taken {
190 self.view_modal.name_error = Some("name already exists".to_string());
191 self.view_modal.form_focus = FormFocus::Name;
192 return;
193 }
194
195 let non_empty = |input: &widgets::text_input::TextInput| {
196 let value = input.value().trim();
197 (!value.is_empty()).then(|| value.to_string())
198 };
199 let match_criteria = view::MatchCriteria {
200 exact_path: non_empty(&self.view_modal.exact_path_input).map(std::path::PathBuf::from),
201 relative_path: non_empty(&self.view_modal.relative_path_input),
202 path_pattern: non_empty(&self.view_modal.path_pattern_input),
203 filename_pattern: non_empty(&self.view_modal.filename_pattern_input),
204 schema_columns: if self.view_modal.schema_match_enabled {
207 self.data_table_state.as_ref().map(|state| {
208 state
209 .source_schema()
210 .iter_names()
211 .map(|s| s.to_string())
212 .collect()
213 })
214 } else {
215 None
216 },
217 schema_types: None,
218 table: self.view_modal.table.clone(),
219 };
220 let description = {
221 let value = self.view_modal.description_input.value();
222 (!value.is_empty()).then(|| value.to_string())
223 };
224
225 let saved = if let Some(editing_id) = self.view_modal.editing_view_id.clone() {
226 let Some(mut view) = self.views.manager.get_view_by_id(&editing_id).cloned() else {
227 return;
228 };
229 view.name = name;
230 view.description = description;
231 let stored_schema = view.match_criteria.schema_columns.take();
232 view.match_criteria = match_criteria;
233 let editing_the_active_view =
234 self.views.active_id.as_deref() == Some(editing_id.as_str());
235 if !editing_the_active_view
238 && self.view_modal.schema_match_enabled
239 && stored_schema.is_some()
240 {
241 view.match_criteria.schema_columns = stored_schema;
242 }
243 if editing_the_active_view && let Some(state) = &self.data_table_state {
246 view.settings = view_settings_of(state);
247 view.settings.chart = self.saved_chart();
248 }
249 match self.views.manager.update_view(&view) {
250 Ok(()) => true,
251 Err(e) => {
252 if self.views.manager.get_view_by_id(&editing_id).is_none() {
254 self.refresh_view_list();
255 self.view_modal.exit_form();
256 }
257 self.error_modal.show(format!("Error saving view: {e}"));
258 return;
259 }
260 }
261 } else {
262 self.create_view_from_current_state(name, description, match_criteria)
263 .is_ok()
264 };
265 if saved {
266 self.refresh_view_list();
267 self.view_modal.exit_form();
268 }
269 }
270
271 pub(crate) fn view_score_details(&self) -> Option<(String, String)> {
273 let state = self.data_table_state.as_ref()?;
274 let path = self.view_dataset()?;
275 let idx = self.view_modal.table_state.selected()?;
276 let row = self.view_modal.rows.get(idx)?;
277 let view = &row.view;
278
279 let exact_path_match = view::exact_path_matches(&view.match_criteria, path);
280 let relative_path_match = view::relative_path_matches(&view.match_criteria, path);
281 let file_cols: std::collections::HashSet<&str> = state
282 .source_schema()
283 .iter_names()
284 .map(|s| s.as_str())
285 .collect();
286 let exact_schema_match =
287 view.match_criteria
288 .schema_columns
289 .as_ref()
290 .is_some_and(|required| {
291 let required: std::collections::HashSet<&str> =
292 required.iter().map(|s| s.as_str()).collect();
293 required.is_subset(&file_cols) && file_cols.len() == required.len()
294 });
295
296 let mut details = format!("Total score: {:.1}\n\n", row.score);
297 if exact_path_match && exact_schema_match {
298 details.push_str("Exact path + exact schema: 2000.0\n");
299 } else if exact_path_match {
300 details.push_str("Exact path: 1000.0\n");
301 } else if relative_path_match && exact_schema_match {
302 details.push_str("Relative path + exact schema: 1950.0\n");
303 } else if relative_path_match {
304 details.push_str("Relative path: 950.0\n");
305 } else if exact_schema_match {
306 details.push_str("Exact schema: 900.0\n");
307 } else {
308 if view::path_pattern_matches(&view.match_criteria, path) {
309 details.push_str("Path pattern match: 50.0+\n");
310 }
311 if view::filename_pattern_matches(&view.match_criteria, path) {
312 details.push_str("Filename pattern match: 30.0+\n");
313 }
314 if let Some(required_cols) = &view.match_criteria.schema_columns {
315 let matching_count = required_cols
316 .iter()
317 .filter(|col| file_cols.contains(col.as_str()))
318 .count();
319 if matching_count > 0 {
320 details.push_str(&format!(
321 "Partial schema match: {:.1} ({} columns)\n",
322 matching_count as f64 * 2.0,
323 matching_count
324 ));
325 }
326 }
327 }
328 if view.usage_count > 0 {
329 details.push_str(&format!(
330 "Usage count: {:.1}\n",
331 (view.usage_count.min(10) as f64) * 1.0
332 ));
333 }
334 if let Some(last_used) = view.last_used
335 && let Ok(duration) = std::time::SystemTime::now().duration_since(last_used)
336 {
337 let days_since = duration.as_secs() / 86400;
338 if days_since <= 7 {
339 details.push_str("Recent usage: 5.0\n");
340 } else if days_since <= 30 {
341 details.push_str("Recent usage: 2.0\n");
342 }
343 }
344 Some((format!("Score: {}", view.name), details))
345 }
346
347 pub(crate) fn apply_view(&mut self, view: &SavedView) -> Result<()> {
352 self.apply_view_with(view, Applying::Asked)
353 }
354
355 pub(crate) fn apply_matched_view(
358 &mut self,
359 view: &SavedView,
360 why: view::MatchReason,
361 ) -> Result<()> {
362 self.apply_view_with(view, Applying::Matched(why))
363 }
364
365 pub(crate) fn restore_place(
369 &mut self,
370 place: view::ViewSettings,
371 active: Option<String>,
372 drill: Option<Box<crate::table::DrillPlace>>,
373 ) -> Result<()> {
374 let view = SavedView {
375 id: active.unwrap_or_default(),
376 name: String::new(),
377 description: None,
378 created: std::time::SystemTime::now(),
379 last_used: None,
380 usage_count: 0,
381 last_matched_file: None,
382 match_criteria: view::MatchCriteria::default(),
383 settings: place,
384 };
385 self.apply_view_with(&view, Applying::Restored(drill))
386 }
387
388 fn apply_view_with(&mut self, view: &SavedView, applying: Applying) -> Result<()> {
389 self.jobs.supersede(|job| matches!(job, Job::ViewPivot(_)));
390 if let Some(saved) = &view.settings.sample {
391 return self.apply_sampled_view(view, saved, &applying);
392 }
393 let Some(state) = self.data_table_state.as_mut() else {
394 return Ok(());
395 };
396 match state.try_transition(|s| Self::replay_view(s, &view.settings, None))? {
397 (Replayed::Planned, rollback) => {
398 self.view_planned(view, rollback, applying);
399 Ok(())
400 }
401 (Replayed::Pivot(job), rollback) => {
402 state.roll_back(rollback);
404 self.jobs.try_advance();
407 let pivot_view = Job::ViewPivot(Box::new((view.clone(), applying)));
408 self.spawn_job(pivot_view, Some(Self::APPLYING_VIEW), move |_| {
409 let pivoted = job
410 .run()
411 .map_err(|e| crate::error_display::user_message_from_report(&e, None))?;
412 Ok(Answer::ViewPivoted(pivoted))
413 });
414 Ok(())
415 }
416 }
417 }
418
419 pub(crate) fn view_planned(
423 &mut self,
424 view: &SavedView,
425 rollback: crate::table::ViewRollback,
426 applying: Applying,
427 ) {
428 let previous = self.mark_view_applied(view, &applying);
429 let Some(state) = self.data_table_state.as_ref() else {
430 return;
431 };
432 self.prompt.query_running = Some(QueryRun {
433 origin: RunOrigin::View {
434 previous,
435 name: view.name.clone(),
436 applying,
437 },
438 frame: state.len_generation(),
439 rollback,
440 counts: self.counting.markers(),
441 rows: None,
442 });
443 if !self.spawn_async_collect(Self::APPLYING_VIEW) {
444 match self.prompt.query_running.as_ref().map(|run| &run.origin) {
447 Some(RunOrigin::View {
448 applying: Applying::Matched(why),
449 ..
450 }) => self.flash_view_applied(&view.name, *why),
451 Some(RunOrigin::View {
453 applying: Applying::Restored(Some(drill)),
454 ..
455 }) => {
456 let gone = crate::table::DrillGone(drill.describe());
457 self.flash_note(gone.to_string());
458 }
459 _ => {}
460 }
461 self.prompt.query_running = None;
462 self.busy = false;
463 self.status_message = None;
464 self.first_rows_settled();
465 }
466 }
467
468 pub(crate) fn mark_view_applied(
472 &mut self,
473 view: &SavedView,
474 applying: &Applying,
475 ) -> Option<String> {
476 if !matches!(applying, Applying::Restored(_))
477 && let Some(path) = &self.path
478 {
479 use crate::logging::LogFailure;
480 self.views
481 .manager
482 .record_use(&view.id, path)
483 .or_log("record a view's use");
484 }
485 self.restore_view_chart(view.settings.chart.as_ref());
486 std::mem::replace(
487 &mut self.views.active_id,
488 (!view.id.is_empty()).then(|| view.id.clone()),
489 )
490 }
491
492 pub(crate) fn view_applying(&self) -> bool {
494 if !self.is_busy() || !self.in_normal_table_view() {
495 return false;
496 }
497 let pivot = self
498 .jobs
499 .current(|job| matches!(job, Job::ViewPivot(_)))
500 .is_some();
501 let rows = self.prompt.query_running.as_ref().is_some_and(|run| {
502 matches!(run.origin, RunOrigin::View { .. })
503 && self
504 .data_table_state
505 .as_ref()
506 .is_some_and(|state| state.len_generation() == run.frame)
507 });
508 pivot || rows
509 }
510
511 pub(crate) fn cancel_view(&mut self) {
514 self.jobs.advance();
515 self.screen_generation = self.screen_generation.wrapping_add(1);
516 if let Some(run) = self.take_query_run() {
517 self.roll_back_query_run(run);
518 }
519 self.forget_the_rows_read();
521 self.read_after_view_rollback();
522 self.flash_note("View cancelled".to_string());
523 }
524
525 pub(crate) fn view_pivot_failed(&mut self, applying: &Applying, message: &str) {
527 self.view_failed(applying, message);
528 self.read_after_view_rollback();
529 }
530
531 pub(crate) fn view_failed(&mut self, applying: &Applying, message: &str) {
533 self.say_read_failed(&view_failure(applying, message));
534 }
535
536 pub(crate) fn read_after_view_rollback(&mut self) {
539 self.busy = false;
540 self.status_message = None;
541 if !self.spawn_async_collect(Self::LOADING_BUFFER) {
542 self.first_rows_settled();
543 }
544 }
545
546 pub(crate) fn replay_view(
551 state: &mut DataTableState,
552 settings: &view::ViewSettings,
553 pivoted: Option<DataFrame>,
554 ) -> Result<Replayed> {
555 if settings.pivot.is_some() || settings.melt.is_some() {
556 if let Some(source) = &settings.reshape_source {
557 Self::replay_query(
558 state,
559 source.sql_query.as_deref(),
560 source.query.as_deref(),
561 source.fuzzy_query.as_deref(),
562 )?;
563 Self::replay_filters_and_sort(
564 state,
565 &source.filters,
566 &source.sort_columns,
567 source.sort_directions(),
568 )?;
569 }
570 let reshaped = match (&settings.pivot, &settings.melt, pivoted) {
571 (Some(spec), _, Some(pivoted)) => state.install_pivot(spec, pivoted),
572 (Some(spec), _, None) => {
573 Self::check_plan(state)?;
574 return Ok(Replayed::Pivot(Box::new(state.plan_pivot(spec))));
575 }
576 (None, Some(spec), _) => state.melt(spec),
577 (None, None, _) => Ok(()),
578 };
579 reshaped.map_err(|e| {
580 color_eyre::eyre::eyre!(
581 "{}",
582 crate::error_display::user_message_from_report(&e, None)
583 )
584 })?;
585 }
586 Self::replay_query(
587 state,
588 settings.sql_query.as_deref(),
589 settings.query.as_deref(),
590 settings.fuzzy_query.as_deref(),
591 )?;
592 if !settings.columns.is_empty() {
594 state.set_column_changes(&settings.columns);
595 }
596 Self::replay_filters_and_sort(
597 state,
598 &settings.filters,
599 &settings.sort_columns,
600 settings.sort_directions(),
601 )?;
602 if !settings.column_order.is_empty() {
603 state.set_column_order(settings.column_order.clone());
604 state.set_locked_columns(settings.locked_columns_count);
605 }
606 Self::check_plan(state)?;
607 Ok(Replayed::Planned)
608 }
609
610 fn check_plan(state: &DataTableState) -> Result<()> {
612 state.check_plan().map_err(|e| {
613 color_eyre::eyre::eyre!("{}", crate::error_display::user_message_from_polars(&e))
614 })
615 }
616
617 fn replay_query(
619 state: &mut DataTableState,
620 sql: Option<&str>,
621 dsl: Option<&str>,
622 fuzzy: Option<&str>,
623 ) -> Result<()> {
624 let stated = |q: Option<&str>| q.filter(|q| !q.trim().is_empty()).map(str::to_string);
625 if let Some(sql) = stated(sql) {
626 state.sql_query(sql);
627 } else if let Some(query) = stated(dsl) {
628 state.query(query);
629 }
630 if state.error().is_none()
631 && let Some(fuzzy) = stated(fuzzy)
632 {
633 state.fuzzy_search(fuzzy);
634 }
635 match state.error().cloned() {
636 Some(error) => Err(color_eyre::eyre::eyre!(
637 "{}",
638 crate::error_display::user_message_from_polars(&error)
639 )),
640 None => Ok(()),
641 }
642 }
643
644 fn replay_filters_and_sort(
646 state: &mut DataTableState,
647 filters: &[FilterStatement],
648 sort_columns: &[String],
649 descending: Vec<bool>,
650 ) -> Result<()> {
651 if !filters.is_empty() {
652 state.filter(filters.to_vec());
653 if let Some(error) = state.error().cloned() {
654 return Err(color_eyre::eyre::eyre!("{}", error));
655 }
656 }
657 if !sort_columns.is_empty() {
658 state.sort_by(sort_columns.to_vec(), descending);
659 if let Some(error) = state.error().cloned() {
660 return Err(color_eyre::eyre::eyre!("{}", error));
661 }
662 }
663 Ok(())
664 }
665
666 pub fn create_view_from_current_state(
667 &mut self,
668 name: String,
669 description: Option<String>,
670 match_criteria: view::MatchCriteria,
671 ) -> Result<view::SavedView> {
672 let settings = match &self.data_table_state {
673 Some(state) => view::ViewSettings {
674 chart: self.saved_chart(),
675 ..view_settings_of(state)
676 },
677 None => view::ViewSettings {
678 chart: None,
679 sample: None,
680 query: None,
681 sql_query: None,
682 fuzzy_query: None,
683 filters: Vec::new(),
684 sort_columns: Vec::new(),
685 sort_descending: Vec::new(),
686 sort_ascending: true,
687 column_order: Vec::new(),
688 locked_columns_count: 0,
689 pivot: None,
690 melt: None,
691 reshape_source: None,
692 columns: Vec::new(),
693 },
694 };
695
696 self.views
697 .manager
698 .create_view(name, description, match_criteria, settings)
699 }
700}