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
21impl SavedViews {
22 pub(crate) fn reset_for_dataset(&mut self) {
24 self.active_id = None;
25 }
26}
27
28impl App {
29 pub(crate) fn open_view_list(&mut self) {
31 if self.view_dataset().is_none() {
32 return;
33 }
34 self.view_modal.table_state.select(Some(0));
35 self.refresh_view_list();
36 self.view_modal.mode = ViewModalMode::List;
37 self.open_overlay(crate::Overlay::View);
38 }
39
40 pub(crate) fn refresh_view_list(&mut self) {
43 let (Some(state), Some(dataset)) = (&self.data_table_state, self.view_dataset()) else {
44 return;
45 };
46 let rows: Vec<ViewRow> = self
47 .views
48 .manager
49 .find_relevant_views(dataset, state.source_schema())
50 .into_iter()
51 .map(|(view, score)| {
52 let reason = view::match_reason(&view, dataset, state.source_schema());
53 ViewRow {
54 view,
55 score,
56 reason,
57 }
58 })
59 .collect();
60 self.view_modal.broken_views = self.views.manager.broken_views.clone();
61 let selected = self.view_modal.table_state.selected().unwrap_or(0);
62 self.view_modal.table_state.select(if rows.is_empty() {
63 None
64 } else {
65 Some(selected.min(rows.len() - 1))
66 });
67 self.view_modal.rows = rows;
68 }
69
70 pub(crate) fn open_save_view_form(&mut self) {
74 self.view_modal
75 .enter_create_mode(self.display.history_limit, &self.theme);
76
77 let query = self.data_table_state.as_ref().and_then(|state| {
78 let (query, sql_query, fuzzy_query) = active_query_settings(
79 state.get_active_query(),
80 state.get_active_sql_query(),
81 state.get_active_fuzzy_query(),
82 );
83 sql_query.or(fuzzy_query).or(query)
84 });
85 self.view_modal.name_input.suggest(
86 self.views
87 .manager
88 .suggest_name(self.path.as_deref(), query.as_deref()),
89 );
90
91 if let Some(path) = self.path.as_ref().filter(|_| !self.reads_stdin()) {
93 let absolute_path = view::exact_location(path);
96 self.view_modal
97 .exact_path_input
98 .suggest(absolute_path.to_string_lossy());
99 if let Some(relative) = view::relative_location(path) {
100 self.view_modal.relative_path_input.suggest(relative);
101 }
102
103 if let Some(parent) = absolute_path.parent()
107 && let Some(parent_str) = parent.to_str()
108 && !parent_str.is_empty()
109 && let Some(ext) = absolute_path.extension()
110 {
111 let separator = if crate::cloud::source::is_remote_url(path) {
112 '/'
113 } else {
114 std::path::MAIN_SEPARATOR
115 };
116 self.view_modal.path_pattern_input.suggest(format!(
117 "{}{separator}*.{}",
118 parent_str.trim_end_matches(separator),
119 ext.to_string_lossy()
120 ));
121 }
122
123 if let Some(filename) = path.file_name()
126 && let Some(filename_str) = filename.to_str()
127 {
128 use regex::Regex;
129 let pattern = match Regex::new(r"\d+") {
130 Ok(re) => re.replace_all(filename_str, "*").to_string(),
131 Err(_) => filename_str.to_string(),
132 };
133 self.view_modal.filename_pattern_input.suggest(pattern);
134 }
135 }
136
137 self.view_modal.table = self.view_table().map(str::to_string);
138
139 if let Some(ref state) = self.data_table_state
142 && !state.source_schema().is_empty()
143 {
144 self.view_modal.schema_match_enabled = true;
145 }
146 }
147
148 pub(crate) fn save_view_form(&mut self) {
151 self.view_modal.name_error = None;
152 let name = self.view_modal.name_input.value().trim().to_string();
153 if name.is_empty() {
154 self.view_modal.name_error = Some("name is required".to_string());
155 self.view_modal.form_focus = FormFocus::Name;
156 return;
157 }
158 let renaming_to_taken = match &self.view_modal.editing_view_id {
159 None => self.views.manager.view_exists(&name),
160 Some(id) => self
161 .views
162 .manager
163 .get_view_by_name(&name)
164 .is_some_and(|other| other.id != *id),
165 };
166 if renaming_to_taken {
167 self.view_modal.name_error = Some("name already exists".to_string());
168 self.view_modal.form_focus = FormFocus::Name;
169 return;
170 }
171
172 let non_empty = |input: &widgets::text_input::TextInput| {
173 let value = input.value().trim();
174 (!value.is_empty()).then(|| value.to_string())
175 };
176 let match_criteria = view::MatchCriteria {
177 exact_path: non_empty(&self.view_modal.exact_path_input).map(std::path::PathBuf::from),
178 relative_path: non_empty(&self.view_modal.relative_path_input),
179 path_pattern: non_empty(&self.view_modal.path_pattern_input),
180 filename_pattern: non_empty(&self.view_modal.filename_pattern_input),
181 schema_columns: if self.view_modal.schema_match_enabled {
184 self.data_table_state.as_ref().map(|state| {
185 state
186 .source_schema()
187 .iter_names()
188 .map(|s| s.to_string())
189 .collect()
190 })
191 } else {
192 None
193 },
194 schema_types: None,
195 table: self.view_modal.table.clone(),
196 };
197 let description = {
198 let value = self.view_modal.description_input.value();
199 (!value.is_empty()).then(|| value.to_string())
200 };
201
202 let saved = if let Some(editing_id) = self.view_modal.editing_view_id.clone() {
203 let Some(mut view) = self.views.manager.get_view_by_id(&editing_id).cloned() else {
204 return;
205 };
206 view.name = name;
207 view.description = description;
208 let stored_schema = view.match_criteria.schema_columns.take();
209 view.match_criteria = match_criteria;
210 let editing_the_active_view =
211 self.views.active_id.as_deref() == Some(editing_id.as_str());
212 if !editing_the_active_view
215 && self.view_modal.schema_match_enabled
216 && stored_schema.is_some()
217 {
218 view.match_criteria.schema_columns = stored_schema;
219 }
220 if editing_the_active_view && let Some(state) = &self.data_table_state {
223 view.settings = view_settings_of(state);
224 view.settings.chart = self.saved_chart();
225 }
226 match self.views.manager.update_view(&view) {
227 Ok(()) => true,
228 Err(e) => {
229 if self.views.manager.get_view_by_id(&editing_id).is_none() {
231 self.refresh_view_list();
232 self.view_modal.exit_form();
233 }
234 self.error_modal.show(format!("Error saving view: {e}"));
235 return;
236 }
237 }
238 } else {
239 self.create_view_from_current_state(name, description, match_criteria)
240 .is_ok()
241 };
242 if saved {
243 self.refresh_view_list();
244 self.view_modal.exit_form();
245 }
246 }
247
248 pub(crate) fn view_score_details(&self) -> Option<(String, String)> {
250 let state = self.data_table_state.as_ref()?;
251 let path = self.view_dataset()?;
252 let idx = self.view_modal.table_state.selected()?;
253 let row = self.view_modal.rows.get(idx)?;
254 let view = &row.view;
255
256 let exact_path_match = view::exact_path_matches(&view.match_criteria, path);
257 let relative_path_match = view::relative_path_matches(&view.match_criteria, path);
258 let file_cols: std::collections::HashSet<&str> = state
259 .source_schema()
260 .iter_names()
261 .map(|s| s.as_str())
262 .collect();
263 let exact_schema_match =
264 view.match_criteria
265 .schema_columns
266 .as_ref()
267 .is_some_and(|required| {
268 let required: std::collections::HashSet<&str> =
269 required.iter().map(|s| s.as_str()).collect();
270 required.is_subset(&file_cols) && file_cols.len() == required.len()
271 });
272
273 let mut details = format!("Total score: {:.1}\n\n", row.score);
274 if exact_path_match && exact_schema_match {
275 details.push_str("Exact path + exact schema: 2000.0\n");
276 } else if exact_path_match {
277 details.push_str("Exact path: 1000.0\n");
278 } else if relative_path_match && exact_schema_match {
279 details.push_str("Relative path + exact schema: 1950.0\n");
280 } else if relative_path_match {
281 details.push_str("Relative path: 950.0\n");
282 } else if exact_schema_match {
283 details.push_str("Exact schema: 900.0\n");
284 } else {
285 if view::path_pattern_matches(&view.match_criteria, path) {
286 details.push_str("Path pattern match: 50.0+\n");
287 }
288 if view::filename_pattern_matches(&view.match_criteria, path) {
289 details.push_str("Filename pattern match: 30.0+\n");
290 }
291 if let Some(required_cols) = &view.match_criteria.schema_columns {
292 let matching_count = required_cols
293 .iter()
294 .filter(|col| file_cols.contains(col.as_str()))
295 .count();
296 if matching_count > 0 {
297 details.push_str(&format!(
298 "Partial schema match: {:.1} ({} columns)\n",
299 matching_count as f64 * 2.0,
300 matching_count
301 ));
302 }
303 }
304 }
305 if view.usage_count > 0 {
306 details.push_str(&format!(
307 "Usage count: {:.1}\n",
308 (view.usage_count.min(10) as f64) * 1.0
309 ));
310 }
311 if let Some(last_used) = view.last_used
312 && let Ok(duration) = std::time::SystemTime::now().duration_since(last_used)
313 {
314 let days_since = duration.as_secs() / 86400;
315 if days_since <= 7 {
316 details.push_str("Recent usage: 5.0\n");
317 } else if days_since <= 30 {
318 details.push_str("Recent usage: 2.0\n");
319 }
320 }
321 Some((format!("Score: {}", view.name), details))
322 }
323
324 pub(crate) fn apply_view(&mut self, view: &SavedView) -> Result<()> {
329 self.apply_view_with(view, None)
330 }
331
332 pub(crate) fn apply_matched_view(
335 &mut self,
336 view: &SavedView,
337 why: view::MatchReason,
338 ) -> Result<()> {
339 self.apply_view_with(view, Some(why))
340 }
341
342 fn apply_view_with(&mut self, view: &SavedView, why: Option<view::MatchReason>) -> Result<()> {
343 self.jobs.supersede(|job| matches!(job, Job::ViewPivot(_)));
344 if let Some(saved) = &view.settings.sample {
345 return self.apply_sampled_view(view, saved, why);
346 }
347 let Some(state) = self.data_table_state.as_mut() else {
348 return Ok(());
349 };
350 match state.try_transition(|s| Self::replay_view(s, &view.settings, None))? {
351 (Replayed::Planned, rollback) => {
352 self.view_planned(view, rollback, why);
353 Ok(())
354 }
355 (Replayed::Pivot(job), rollback) => {
356 state.roll_back(rollback);
358 self.jobs.try_advance();
361 let pivot_view = Job::ViewPivot(Box::new((view.clone(), why)));
362 self.spawn_job(pivot_view, Some(Self::APPLYING_VIEW), move |_| {
363 let pivoted = job
364 .run()
365 .map_err(|e| crate::error_display::user_message_from_report(&e, None))?;
366 Ok(Answer::ViewPivoted(pivoted))
367 });
368 Ok(())
369 }
370 }
371 }
372
373 pub(crate) fn view_planned(
377 &mut self,
378 view: &SavedView,
379 rollback: crate::table::ViewRollback,
380 why: Option<view::MatchReason>,
381 ) {
382 if let Some(path) = &self.path {
383 use crate::logging::LogFailure;
384 self.views
385 .manager
386 .record_use(&view.id, path)
387 .or_log("record a view's use");
388 }
389 let previous = self.views.active_id.replace(view.id.clone());
390 self.restore_view_chart(view.settings.chart.as_ref());
391 let Some(state) = self.data_table_state.as_ref() else {
392 return;
393 };
394 self.prompt.query_running = Some(QueryRun {
395 origin: RunOrigin::View {
396 previous,
397 matched: why.map(|why| (view.name.clone(), why)),
398 },
399 frame: state.len_generation(),
400 rollback,
401 counts: self.counting.markers(),
402 rows: None,
403 });
404 if !self.spawn_async_collect(Self::APPLYING_VIEW) {
405 if let Some(why) = why {
408 self.flash_view_applied(&view.name, why);
409 }
410 self.prompt.query_running = None;
411 self.busy = false;
412 self.status_message = None;
413 self.first_rows_settled();
414 }
415 }
416
417 pub(crate) fn view_applying(&self) -> bool {
419 if !self.is_busy() || !self.in_normal_table_view() {
420 return false;
421 }
422 let pivot = self
423 .jobs
424 .current(|job| matches!(job, Job::ViewPivot(_)))
425 .is_some();
426 let rows = self.prompt.query_running.as_ref().is_some_and(|run| {
427 matches!(run.origin, RunOrigin::View { .. })
428 && self
429 .data_table_state
430 .as_ref()
431 .is_some_and(|state| state.len_generation() == run.frame)
432 });
433 pivot || rows
434 }
435
436 pub(crate) fn cancel_view(&mut self) {
439 self.jobs.advance();
440 self.screen_generation = self.screen_generation.wrapping_add(1);
441 if let Some(run) = self.take_query_run() {
442 self.roll_back_query_run(run);
443 }
444 self.forget_the_rows_read();
446 self.read_after_view_rollback();
447 self.flash_note("View cancelled".to_string());
448 }
449
450 pub(crate) fn view_pivot_failed(&mut self, message: &str) {
452 self.error_modal
453 .show(format!("Error applying view: {message}"));
454 self.read_after_view_rollback();
455 }
456
457 pub(crate) fn read_after_view_rollback(&mut self) {
460 self.busy = false;
461 self.status_message = None;
462 if !self.spawn_async_collect(Self::LOADING_BUFFER) {
463 self.first_rows_settled();
464 }
465 }
466
467 pub(crate) fn replay_view(
472 state: &mut DataTableState,
473 settings: &view::ViewSettings,
474 pivoted: Option<DataFrame>,
475 ) -> Result<Replayed> {
476 if settings.pivot.is_some() || settings.melt.is_some() {
477 if let Some(source) = &settings.reshape_source {
478 Self::replay_query(
479 state,
480 source.sql_query.as_deref(),
481 source.query.as_deref(),
482 source.fuzzy_query.as_deref(),
483 )?;
484 Self::replay_filters_and_sort(
485 state,
486 &source.filters,
487 &source.sort_columns,
488 source.sort_directions(),
489 )?;
490 }
491 let reshaped = match (&settings.pivot, &settings.melt, pivoted) {
492 (Some(spec), _, Some(pivoted)) => state.install_pivot(spec, pivoted),
493 (Some(spec), _, None) => {
494 Self::check_plan(state)?;
495 return Ok(Replayed::Pivot(Box::new(state.plan_pivot(spec))));
496 }
497 (None, Some(spec), _) => state.melt(spec),
498 (None, None, _) => Ok(()),
499 };
500 reshaped.map_err(|e| {
501 color_eyre::eyre::eyre!(
502 "{}",
503 crate::error_display::user_message_from_report(&e, None)
504 )
505 })?;
506 }
507 Self::replay_query(
508 state,
509 settings.sql_query.as_deref(),
510 settings.query.as_deref(),
511 settings.fuzzy_query.as_deref(),
512 )?;
513 if !settings.columns.is_empty() {
515 state.set_column_changes(&settings.columns);
516 }
517 Self::replay_filters_and_sort(
518 state,
519 &settings.filters,
520 &settings.sort_columns,
521 settings.sort_directions(),
522 )?;
523 if !settings.column_order.is_empty() {
524 state.set_column_order(settings.column_order.clone());
525 state.set_locked_columns(settings.locked_columns_count);
526 }
527 Self::check_plan(state)?;
528 Ok(Replayed::Planned)
529 }
530
531 fn check_plan(state: &DataTableState) -> Result<()> {
533 state.check_plan().map_err(|e| {
534 color_eyre::eyre::eyre!("{}", crate::error_display::user_message_from_polars(&e))
535 })
536 }
537
538 fn replay_query(
540 state: &mut DataTableState,
541 sql: Option<&str>,
542 dsl: Option<&str>,
543 fuzzy: Option<&str>,
544 ) -> Result<()> {
545 let stated = |q: Option<&str>| q.filter(|q| !q.trim().is_empty()).map(str::to_string);
546 if let Some(sql) = stated(sql) {
547 state.sql_query(sql);
548 } else if let Some(query) = stated(dsl) {
549 state.query(query);
550 }
551 if state.error().is_none()
552 && let Some(fuzzy) = stated(fuzzy)
553 {
554 state.fuzzy_search(fuzzy);
555 }
556 match state.error().cloned() {
557 Some(error) => Err(color_eyre::eyre::eyre!(
558 "{}",
559 crate::error_display::user_message_from_polars(&error)
560 )),
561 None => Ok(()),
562 }
563 }
564
565 fn replay_filters_and_sort(
567 state: &mut DataTableState,
568 filters: &[FilterStatement],
569 sort_columns: &[String],
570 descending: Vec<bool>,
571 ) -> Result<()> {
572 if !filters.is_empty() {
573 state.filter(filters.to_vec());
574 if let Some(error) = state.error().cloned() {
575 return Err(color_eyre::eyre::eyre!("{}", error));
576 }
577 }
578 if !sort_columns.is_empty() {
579 state.sort_by(sort_columns.to_vec(), descending);
580 if let Some(error) = state.error().cloned() {
581 return Err(color_eyre::eyre::eyre!("{}", error));
582 }
583 }
584 Ok(())
585 }
586
587 pub fn create_view_from_current_state(
588 &mut self,
589 name: String,
590 description: Option<String>,
591 match_criteria: view::MatchCriteria,
592 ) -> Result<view::SavedView> {
593 let settings = match &self.data_table_state {
594 Some(state) => view::ViewSettings {
595 chart: self.saved_chart(),
596 ..view_settings_of(state)
597 },
598 None => view::ViewSettings {
599 chart: None,
600 sample: None,
601 query: None,
602 sql_query: None,
603 fuzzy_query: None,
604 filters: Vec::new(),
605 sort_columns: Vec::new(),
606 sort_descending: Vec::new(),
607 sort_ascending: true,
608 column_order: Vec::new(),
609 locked_columns_count: 0,
610 pivot: None,
611 melt: None,
612 reshape_source: None,
613 columns: Vec::new(),
614 },
615 };
616
617 self.views
618 .manager
619 .create_view(name, description, match_criteria, settings)
620 }
621}