1use color_eyre::Result;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use polars::datatypes::AnyValue;
4use polars::datatypes::DataType;
5#[cfg(feature = "cloud")]
6use polars::io::cloud::{AmazonS3ConfigKey, CloudOptions};
7use polars::prelude::{col, len, DataFrame, LazyFrame, Schema};
8#[cfg(feature = "cloud")]
9use polars::prelude::{PlPathRef, ScanArgsParquet};
10use std::path::{Path, PathBuf};
11use std::sync::{mpsc::Sender, Arc};
12use widgets::info::{read_parquet_metadata, InfoFocus, InfoModal, InfoTab, ParquetMetadataCache};
13
14use ratatui::style::{Color, Style};
15use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
16
17use ratatui::widgets::{Block, Clear};
18
19pub mod analysis_modal;
20pub mod cache;
21pub mod chart_data;
22pub mod chart_export;
23pub mod chart_export_modal;
24pub mod chart_modal;
25pub mod cli;
26#[cfg(feature = "cloud")]
27mod cloud_hive;
28pub mod config;
29pub mod error_display;
30pub mod export_modal;
31pub mod filter_modal;
32pub(crate) mod help_strings;
33pub mod pivot_melt_modal;
34mod query;
35mod render;
36pub mod sort_filter_modal;
37pub mod sort_modal;
38mod source;
39pub mod statistics;
40pub mod template;
41pub mod widgets;
42
43pub use cache::CacheManager;
44pub use cli::Args;
45pub use config::{
46 rgb_to_256_color, rgb_to_basic_ansi, AppConfig, ColorParser, ConfigManager, Theme,
47};
48
49use analysis_modal::{AnalysisModal, AnalysisProgress};
50use chart_export::{
51 write_box_plot_eps, write_box_plot_png, write_chart_eps, write_chart_png, write_heatmap_eps,
52 write_heatmap_png, BoxPlotExportBounds, ChartExportBounds, ChartExportFormat,
53 ChartExportSeries,
54};
55use chart_export_modal::{ChartExportFocus, ChartExportModal};
56use chart_modal::{ChartFocus, ChartKind, ChartModal, ChartType};
57pub use error_display::{error_for_python, ErrorKindForPython};
58use export_modal::{ExportFocus, ExportFormat, ExportModal};
59use filter_modal::{FilterFocus, FilterOperator, FilterStatement, LogicalOperator};
60use pivot_melt_modal::{MeltSpec, PivotMeltFocus, PivotMeltModal, PivotMeltTab, PivotSpec};
61use sort_filter_modal::{SortFilterFocus, SortFilterModal, SortFilterTab};
62use sort_modal::{SortColumn, SortFocus};
63pub use template::{Template, TemplateManager};
64use widgets::controls::Controls;
65use widgets::datatable::DataTableState;
66use widgets::debug::DebugState;
67use widgets::template_modal::{CreateFocus, TemplateFocus, TemplateModal, TemplateModalMode};
68use widgets::text_input::{TextInput, TextInputEvent};
69
70pub const APP_NAME: &str = "datui";
72
73pub use cli::CompressionFormat;
75
76#[cfg(test)]
77pub mod tests {
78 use std::path::Path;
79 use std::process::Command;
80 use std::sync::Once;
81
82 static INIT: Once = Once::new();
83
84 pub fn ensure_sample_data() {
88 INIT.call_once(|| {
89 let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
91 let sample_data_dir = repo_root.join("tests/sample-data");
92
93 let key_files = [
96 "people.parquet",
97 "sales.parquet",
98 "large_dataset.parquet",
99 "empty.parquet",
100 "pivot_long.parquet",
101 "melt_wide.parquet",
102 ];
103
104 let needs_generation = !sample_data_dir.exists()
105 || key_files
106 .iter()
107 .any(|file| !sample_data_dir.join(file).exists());
108
109 if needs_generation {
110 let script_path = repo_root.join("scripts/generate_sample_data.py");
112 if !script_path.exists() {
113 panic!(
114 "Sample data generation script not found at: {}. \
115 Please ensure you're running tests from the repository root.",
116 script_path.display()
117 );
118 }
119
120 let python_cmd = if Command::new("python3").arg("--version").output().is_ok() {
122 "python3"
123 } else if Command::new("python").arg("--version").output().is_ok() {
124 "python"
125 } else {
126 panic!(
127 "Python not found. Please install Python 3 to generate test data. \
128 The script requires: polars>=0.20.0 and numpy>=1.24.0"
129 );
130 };
131
132 let output = Command::new(python_cmd)
134 .arg(script_path)
135 .output()
136 .unwrap_or_else(|e| {
137 panic!(
138 "Failed to run sample data generation script: {}. \
139 Make sure Python is installed and the script is executable.",
140 e
141 );
142 });
143
144 if !output.status.success() {
145 let stderr = String::from_utf8_lossy(&output.stderr);
146 let stdout = String::from_utf8_lossy(&output.stdout);
147 panic!(
148 "Sample data generation failed!\n\
149 Exit code: {:?}\n\
150 stdout:\n{}\n\
151 stderr:\n{}",
152 output.status.code(),
153 stdout,
154 stderr
155 );
156 }
157 }
158 });
159 }
160
161 pub fn sample_data_dir() -> std::path::PathBuf {
163 ensure_sample_data();
164 Path::new(env!("CARGO_MANIFEST_DIR"))
165 .join("../..")
166 .join("tests/sample-data")
167 }
168
169 #[test]
171 fn test_active_query_settings_only_one_set() {
172 use super::active_query_settings;
173
174 let (q, sql, fuzzy) = active_query_settings("", "", "");
175 assert!(q.is_none() && sql.is_none() && fuzzy.is_none());
176
177 let (q, sql, fuzzy) = active_query_settings("select a", "SELECT 1", "foo");
178 assert!(q.is_none() && sql.as_deref() == Some("SELECT 1") && fuzzy.is_none());
179
180 let (q, sql, fuzzy) = active_query_settings("select a", "", "foo bar");
181 assert!(q.is_none() && sql.is_none() && fuzzy.as_deref() == Some("foo bar"));
182
183 let (q, sql, fuzzy) = active_query_settings(" select a ", "", "");
184 assert!(q.as_deref() == Some("select a") && sql.is_none() && fuzzy.is_none());
185 }
186}
187
188#[derive(Clone)]
189pub struct OpenOptions {
190 pub delimiter: Option<u8>,
191 pub has_header: Option<bool>,
192 pub skip_lines: Option<usize>,
193 pub skip_rows: Option<usize>,
194 pub compression: Option<CompressionFormat>,
195 pub pages_lookahead: Option<usize>,
196 pub pages_lookback: Option<usize>,
197 pub max_buffered_rows: Option<usize>,
198 pub max_buffered_mb: Option<usize>,
199 pub row_numbers: bool,
200 pub row_start_index: usize,
201 pub hive: bool,
203 pub single_spine_schema: bool,
205 pub parse_dates: bool,
207 pub decompress_in_memory: bool,
209 pub temp_dir: Option<std::path::PathBuf>,
211 pub excel_sheet: Option<String>,
213 pub s3_endpoint_url_override: Option<String>,
215 pub s3_access_key_id_override: Option<String>,
216 pub s3_secret_access_key_override: Option<String>,
217 pub s3_region_override: Option<String>,
218 pub polars_streaming: bool,
220 pub workaround_pivot_date_index: bool,
222}
223
224impl OpenOptions {
225 pub fn new() -> Self {
226 Self {
227 delimiter: None,
228 has_header: None,
229 skip_lines: None,
230 skip_rows: None,
231 compression: None,
232 pages_lookahead: None,
233 pages_lookback: None,
234 max_buffered_rows: None,
235 max_buffered_mb: None,
236 row_numbers: false,
237 row_start_index: 1,
238 hive: false,
239 single_spine_schema: true,
240 parse_dates: true,
241 decompress_in_memory: false,
242 temp_dir: None,
243 excel_sheet: None,
244 s3_endpoint_url_override: None,
245 s3_access_key_id_override: None,
246 s3_secret_access_key_override: None,
247 s3_region_override: None,
248 polars_streaming: true,
249 workaround_pivot_date_index: true,
250 }
251 }
252}
253
254impl Default for OpenOptions {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260impl OpenOptions {
261 pub fn with_skip_lines(mut self, skip_lines: usize) -> Self {
262 self.skip_lines = Some(skip_lines);
263 self
264 }
265
266 pub fn with_skip_rows(mut self, skip_rows: usize) -> Self {
267 self.skip_rows = Some(skip_rows);
268 self
269 }
270
271 pub fn with_delimiter(mut self, delimiter: u8) -> Self {
272 self.delimiter = Some(delimiter);
273 self
274 }
275
276 pub fn with_has_header(mut self, has_header: bool) -> Self {
277 self.has_header = Some(has_header);
278 self
279 }
280
281 pub fn with_compression(mut self, compression: CompressionFormat) -> Self {
282 self.compression = Some(compression);
283 self
284 }
285
286 pub fn with_workaround_pivot_date_index(mut self, workaround_pivot_date_index: bool) -> Self {
287 self.workaround_pivot_date_index = workaround_pivot_date_index;
288 self
289 }
290}
291
292impl OpenOptions {
293 pub fn from_args_and_config(args: &cli::Args, config: &AppConfig) -> Self {
295 let mut opts = OpenOptions::new();
296
297 opts.delimiter = args.delimiter.or(config.file_loading.delimiter);
299 opts.skip_lines = args.skip_lines.or(config.file_loading.skip_lines);
300 opts.skip_rows = args.skip_rows.or(config.file_loading.skip_rows);
301
302 opts.has_header = if let Some(no_header) = args.no_header {
304 Some(!no_header)
305 } else {
306 config.file_loading.has_header
307 };
308
309 opts.compression = args.compression;
311
312 opts.pages_lookahead = args
314 .pages_lookahead
315 .or(Some(config.display.pages_lookahead));
316 opts.pages_lookback = args.pages_lookback.or(Some(config.display.pages_lookback));
317 opts.max_buffered_rows = Some(config.display.max_buffered_rows);
318 opts.max_buffered_mb = Some(config.display.max_buffered_mb);
319
320 opts.row_numbers = args.row_numbers || config.display.row_numbers;
322
323 opts.row_start_index = args
325 .row_start_index
326 .unwrap_or(config.display.row_start_index);
327
328 opts.hive = args.hive;
330
331 opts.single_spine_schema = args
333 .single_spine_schema
334 .or(config.file_loading.single_spine_schema)
335 .unwrap_or(true);
336
337 opts.parse_dates = args
339 .parse_dates
340 .or(config.file_loading.parse_dates)
341 .unwrap_or(true);
342
343 opts.decompress_in_memory = args
345 .decompress_in_memory
346 .or(config.file_loading.decompress_in_memory)
347 .unwrap_or(false);
348
349 opts.temp_dir = args.temp_dir.clone().or_else(|| {
351 config
352 .file_loading
353 .temp_dir
354 .as_ref()
355 .map(std::path::PathBuf::from)
356 });
357
358 opts.excel_sheet = args.excel_sheet.clone();
360
361 opts.s3_endpoint_url_override = args
363 .s3_endpoint_url
364 .clone()
365 .or_else(|| std::env::var("AWS_ENDPOINT_URL_S3").ok())
366 .or_else(|| std::env::var("AWS_ENDPOINT_URL").ok());
367 opts.s3_access_key_id_override = args
368 .s3_access_key_id
369 .clone()
370 .or_else(|| std::env::var("AWS_ACCESS_KEY_ID").ok());
371 opts.s3_secret_access_key_override = args
372 .s3_secret_access_key
373 .clone()
374 .or_else(|| std::env::var("AWS_SECRET_ACCESS_KEY").ok());
375 opts.s3_region_override = args
376 .s3_region
377 .clone()
378 .or_else(|| std::env::var("AWS_REGION").ok())
379 .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok());
380
381 opts.polars_streaming = config.performance.polars_streaming;
382
383 opts.workaround_pivot_date_index = args.workaround_pivot_date_index.unwrap_or(true);
384
385 opts
386 }
387}
388
389impl From<&cli::Args> for OpenOptions {
390 fn from(args: &cli::Args) -> Self {
391 let config = AppConfig::default();
393 Self::from_args_and_config(args, &config)
394 }
395}
396
397pub enum AppEvent {
398 Key(KeyEvent),
399 Open(Vec<PathBuf>, OpenOptions),
400 OpenLazyFrame(Box<LazyFrame>, OpenOptions),
402 DoLoad(Vec<PathBuf>, OpenOptions), DoLoadScanPaths(Vec<PathBuf>, OpenOptions),
405 #[cfg(feature = "http")]
407 DoDownloadHttp(String, OpenOptions),
408 #[cfg(feature = "cloud")]
410 DoDownloadS3ToTemp(String, OpenOptions),
411 #[cfg(feature = "cloud")]
413 DoDownloadGcsToTemp(String, OpenOptions),
414 #[cfg(any(feature = "http", feature = "cloud"))]
416 DoLoadFromHttpTemp(PathBuf, OpenOptions),
417 DoLoadSchema(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
419 DoLoadSchemaBlocking(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
421 DoLoadBuffer,
423 DoDecompress(Vec<PathBuf>, OpenOptions), DoExport(PathBuf, ExportFormat, ExportOptions), DoExportCollect(PathBuf, ExportFormat, ExportOptions), DoExportWrite(PathBuf, ExportFormat, ExportOptions), DoLoadParquetMetadata, Exit,
429 Crash(String),
430 Search(String),
431 SqlSearch(String),
432 FuzzySearch(String),
433 Filter(Vec<FilterStatement>),
434 Sort(Vec<String>, bool), ColumnOrder(Vec<String>, usize), Pivot(PivotSpec),
437 Melt(MeltSpec),
438 Export(PathBuf, ExportFormat, ExportOptions), ChartExport(PathBuf, ChartExportFormat, String), DoChartExport(PathBuf, ChartExportFormat, String), Collect,
442 Update,
443 Reset,
444 Resize(u16, u16), DoScrollDown, DoScrollUp, DoScrollNext, DoScrollPrev, DoScrollEnd, DoScrollHalfDown, DoScrollHalfUp, GoToLine(usize), AnalysisChunk,
455 AnalysisDistributionCompute,
457 AnalysisCorrelationCompute,
459}
460
461#[derive(Clone)]
463pub enum RunInput {
464 Paths(Vec<PathBuf>, OpenOptions),
465 LazyFrame(Box<LazyFrame>, OpenOptions),
466}
467
468#[derive(Debug, Clone)]
469pub struct ExportOptions {
470 pub csv_delimiter: u8,
471 pub csv_include_header: bool,
472 pub csv_compression: Option<CompressionFormat>,
473 pub json_compression: Option<CompressionFormat>,
474 pub ndjson_compression: Option<CompressionFormat>,
475 pub parquet_compression: Option<CompressionFormat>, }
477
478#[derive(Debug, Default, PartialEq, Eq)]
479pub enum InputMode {
480 #[default]
481 Normal,
482 SortFilter,
483 PivotMelt,
484 Editing,
485 Export,
486 Info,
487 Chart,
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub enum InputType {
492 Search,
493 Filter,
494 GoToLine,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
499pub enum QueryTab {
500 #[default]
501 SqlLike,
502 Fuzzy,
503 Sql,
504}
505
506impl QueryTab {
507 fn next(self) -> Self {
508 match self {
509 QueryTab::SqlLike => QueryTab::Fuzzy,
510 QueryTab::Fuzzy => QueryTab::Sql,
511 QueryTab::Sql => QueryTab::SqlLike,
512 }
513 }
514 fn prev(self) -> Self {
515 match self {
516 QueryTab::SqlLike => QueryTab::Sql,
517 QueryTab::Fuzzy => QueryTab::SqlLike,
518 QueryTab::Sql => QueryTab::Fuzzy,
519 }
520 }
521 fn index(self) -> usize {
522 match self {
523 QueryTab::SqlLike => 0,
524 QueryTab::Fuzzy => 1,
525 QueryTab::Sql => 2,
526 }
527 }
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
532pub enum QueryFocus {
533 TabBar,
534 #[default]
535 Input,
536}
537
538#[derive(Default)]
539pub struct ErrorModal {
540 pub active: bool,
541 pub message: String,
542}
543
544impl ErrorModal {
545 pub fn new() -> Self {
546 Self::default()
547 }
548
549 pub fn show(&mut self, message: String) {
550 self.active = true;
551 self.message = message;
552 }
553
554 pub fn hide(&mut self) {
555 self.active = false;
556 self.message.clear();
557 }
558}
559
560#[derive(Default)]
561pub struct SuccessModal {
562 pub active: bool,
563 pub message: String,
564}
565
566impl SuccessModal {
567 pub fn new() -> Self {
568 Self::default()
569 }
570
571 pub fn show(&mut self, message: String) {
572 self.active = true;
573 self.message = message;
574 }
575
576 pub fn hide(&mut self) {
577 self.active = false;
578 self.message.clear();
579 }
580}
581
582#[derive(Default)]
583pub struct ConfirmationModal {
584 pub active: bool,
585 pub message: String,
586 pub focus_yes: bool, }
588
589impl ConfirmationModal {
590 pub fn new() -> Self {
591 Self::default()
592 }
593
594 pub fn show(&mut self, message: String) {
595 self.active = true;
596 self.message = message;
597 self.focus_yes = true; }
599
600 pub fn hide(&mut self) {
601 self.active = false;
602 self.message.clear();
603 self.focus_yes = true;
604 }
605}
606
607#[cfg(any(feature = "http", feature = "cloud"))]
609#[derive(Clone)]
610pub enum PendingDownload {
611 #[cfg(feature = "http")]
612 Http {
613 url: String,
614 size: Option<u64>,
615 options: OpenOptions,
616 },
617 #[cfg(feature = "cloud")]
618 S3 {
619 url: String,
620 size: Option<u64>,
621 options: OpenOptions,
622 },
623 #[cfg(feature = "cloud")]
624 Gcs {
625 url: String,
626 size: Option<u64>,
627 options: OpenOptions,
628 },
629}
630
631#[derive(Clone, Debug, Default)]
632pub enum LoadingState {
633 #[default]
634 Idle,
635 Loading {
636 file_path: Option<PathBuf>,
638 file_size: u64, current_phase: String, progress_percent: u16, },
642 Exporting {
643 file_path: PathBuf,
644 current_phase: String, progress_percent: u16, },
647}
648
649impl LoadingState {
650 pub fn is_loading(&self) -> bool {
651 matches!(
652 self,
653 LoadingState::Loading { .. } | LoadingState::Exporting { .. }
654 )
655 }
656}
657
658#[allow(dead_code)]
660struct AnalysisComputationState {
661 df: Option<DataFrame>,
662 schema: Option<Arc<Schema>>,
663 partial_stats: Vec<crate::statistics::ColumnStatistics>,
664 current: usize,
665 total: usize,
666 total_rows: usize,
667 sample_seed: u64,
668 sample_size: Option<usize>,
669}
670
671fn active_query_settings(
674 dsl_query: &str,
675 sql_query: &str,
676 fuzzy_query: &str,
677) -> (Option<String>, Option<String>, Option<String>) {
678 let sql_trimmed = sql_query.trim();
679 let fuzzy_trimmed = fuzzy_query.trim();
680 let dsl_trimmed = dsl_query.trim();
681 if !sql_trimmed.is_empty() {
682 (None, Some(sql_trimmed.to_string()), None)
683 } else if !fuzzy_trimmed.is_empty() {
684 (None, None, Some(fuzzy_trimmed.to_string()))
685 } else if !dsl_trimmed.is_empty() {
686 (Some(dsl_trimmed.to_string()), None, None)
687 } else {
688 (None, None, None)
689 }
690}
691
692struct TemplateApplicationState {
694 lf: LazyFrame,
695 schema: Arc<Schema>,
696 active_query: String,
697 active_sql_query: String,
698 active_fuzzy_query: String,
699 filters: Vec<FilterStatement>,
700 sort_columns: Vec<String>,
701 sort_ascending: bool,
702 column_order: Vec<String>,
703 locked_columns_count: usize,
704}
705
706#[derive(Default)]
707pub(crate) struct ChartCache {
708 pub(crate) xy: Option<ChartCacheXY>,
709 pub(crate) x_range: Option<ChartCacheXRange>,
710 pub(crate) histogram: Option<ChartCacheHistogram>,
711 pub(crate) box_plot: Option<ChartCacheBoxPlot>,
712 pub(crate) kde: Option<ChartCacheKde>,
713 pub(crate) heatmap: Option<ChartCacheHeatmap>,
714}
715
716impl ChartCache {
717 fn clear(&mut self) {
718 *self = Self::default();
719 }
720}
721
722pub(crate) struct ChartCacheXY {
723 pub(crate) x_column: String,
724 pub(crate) y_columns: Vec<String>,
725 pub(crate) row_limit: Option<usize>,
726 pub(crate) series: Vec<Vec<(f64, f64)>>,
727 pub(crate) series_log: Option<Vec<Vec<(f64, f64)>>>,
728 pub(crate) x_axis_kind: chart_data::XAxisTemporalKind,
729}
730
731pub(crate) struct ChartCacheXRange {
732 pub(crate) x_column: String,
733 pub(crate) row_limit: Option<usize>,
734 pub(crate) x_min: f64,
735 pub(crate) x_max: f64,
736 pub(crate) x_axis_kind: chart_data::XAxisTemporalKind,
737}
738
739pub(crate) struct ChartCacheHistogram {
740 pub(crate) column: String,
741 pub(crate) bins: usize,
742 pub(crate) row_limit: Option<usize>,
743 pub(crate) data: chart_data::HistogramData,
744}
745
746pub(crate) struct ChartCacheBoxPlot {
747 pub(crate) column: String,
748 pub(crate) row_limit: Option<usize>,
749 pub(crate) data: chart_data::BoxPlotData,
750}
751
752pub(crate) struct ChartCacheKde {
753 pub(crate) column: String,
754 pub(crate) bandwidth_factor: f64,
755 pub(crate) row_limit: Option<usize>,
756 pub(crate) data: chart_data::KdeData,
757}
758
759pub(crate) struct ChartCacheHeatmap {
760 pub(crate) x_column: String,
761 pub(crate) y_column: String,
762 pub(crate) bins: usize,
763 pub(crate) row_limit: Option<usize>,
764 pub(crate) data: chart_data::HeatmapData,
765}
766
767pub struct App {
768 pub data_table_state: Option<DataTableState>,
769 path: Option<PathBuf>,
770 original_file_format: Option<ExportFormat>, original_file_delimiter: Option<u8>, events: Sender<AppEvent>,
773 focus: u32,
774 debug: DebugState,
775 info_modal: InfoModal,
776 parquet_metadata_cache: Option<ParquetMetadataCache>,
777 query_input: TextInput, sql_input: TextInput, fuzzy_input: TextInput, pub input_mode: InputMode,
781 input_type: Option<InputType>,
782 query_tab: QueryTab,
783 query_focus: QueryFocus,
784 pub sort_filter_modal: SortFilterModal,
785 pub pivot_melt_modal: PivotMeltModal,
786 pub template_modal: TemplateModal,
787 pub analysis_modal: AnalysisModal,
788 pub chart_modal: ChartModal,
789 pub chart_export_modal: ChartExportModal,
790 pub export_modal: ExportModal,
791 pub(crate) chart_cache: ChartCache,
792 error_modal: ErrorModal,
793 success_modal: SuccessModal,
794 confirmation_modal: ConfirmationModal,
795 pending_export: Option<(PathBuf, ExportFormat, ExportOptions)>, export_df: Option<DataFrame>,
798 pending_chart_export: Option<(PathBuf, ChartExportFormat, String)>,
799 #[cfg(any(feature = "http", feature = "cloud"))]
801 pending_download: Option<PendingDownload>,
802 show_help: bool,
803 help_scroll: usize, cache: CacheManager,
805 template_manager: TemplateManager,
806 active_template_id: Option<String>, loading_state: LoadingState, theme: Theme, sampling_threshold: Option<usize>, history_limit: usize, table_cell_padding: u16, column_colors: bool, busy: bool, throbber_frame: u8, drain_keys_on_next_loop: bool, analysis_computation: Option<AnalysisComputationState>,
817 app_config: AppConfig,
818 #[cfg(feature = "http")]
820 http_temp_path: Option<PathBuf>,
821}
822
823impl App {
824 pub fn should_drain_keys(&self) -> bool {
826 self.drain_keys_on_next_loop
827 }
828
829 pub fn clear_drain_keys_request(&mut self) {
831 self.drain_keys_on_next_loop = false;
832 }
833
834 pub fn send_event(&mut self, event: AppEvent) -> Result<()> {
835 self.events.send(event)?;
836 Ok(())
837 }
838
839 pub fn set_loading_phase(&mut self, phase: impl Into<String>, progress_percent: u16) {
842 self.busy = true;
843 self.loading_state = LoadingState::Loading {
844 file_path: None,
845 file_size: 0,
846 current_phase: phase.into(),
847 progress_percent,
848 };
849 }
850
851 fn ensure_file_extension(
855 path: &Path,
856 format: ExportFormat,
857 compression: Option<CompressionFormat>,
858 ) -> PathBuf {
859 let current_ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
860 let mut new_path = path.to_path_buf();
861
862 if current_ext.is_empty() {
863 let desired_ext = if let Some(comp) = compression {
865 format!("{}.{}", format.extension(), comp.extension())
866 } else {
867 format.extension().to_string()
868 };
869 new_path.set_extension(&desired_ext);
870 } else {
871 let is_compression_only = matches!(
873 current_ext.to_lowercase().as_str(),
874 "gz" | "zst" | "bz2" | "xz"
875 ) && ExportFormat::from_extension(current_ext).is_none();
876
877 if is_compression_only {
878 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
880 let stem_has_format = stem
881 .split('.')
882 .next_back()
883 .and_then(ExportFormat::from_extension)
884 .is_some();
885 if stem_has_format {
886 if let Some(comp) = compression {
887 if let Some(format_ext) = stem
888 .split('.')
889 .next_back()
890 .and_then(ExportFormat::from_extension)
891 .map(|f| f.extension())
892 {
893 new_path =
894 PathBuf::from(stem.rsplit_once('.').map(|x| x.0).unwrap_or(stem));
895 new_path.set_extension(format!("{}.{}", format_ext, comp.extension()));
896 }
897 }
898 } else if let Some(comp) = compression {
899 new_path.set_extension(format!("{}.{}", format.extension(), comp.extension()));
900 } else {
901 new_path.set_extension(format.extension());
902 }
903 } else if let Some(comp) = compression {
904 if format.supports_compression() {
905 new_path.set_extension(format!("{}.{}", current_ext, comp.extension()));
906 }
907 }
909 }
911
912 new_path
913 }
914
915 pub fn new(events: Sender<AppEvent>) -> App {
916 let theme = Theme::from_config(&AppConfig::default().theme).unwrap_or_else(|_| {
918 Theme {
920 colors: std::collections::HashMap::new(),
921 }
922 });
923
924 Self::new_with_config(events, theme, AppConfig::default())
925 }
926
927 pub fn new_with_theme(events: Sender<AppEvent>, theme: Theme) -> App {
928 Self::new_with_config(events, theme, AppConfig::default())
929 }
930
931 pub fn new_with_config(events: Sender<AppEvent>, theme: Theme, app_config: AppConfig) -> App {
932 let cache = CacheManager::new(APP_NAME).unwrap_or_else(|_| CacheManager {
933 cache_dir: std::env::temp_dir().join(APP_NAME),
934 });
935
936 let config_manager = ConfigManager::new(APP_NAME).unwrap_or_else(|_| ConfigManager {
937 config_dir: std::env::temp_dir().join(APP_NAME).join("config"),
938 });
939
940 let template_manager = TemplateManager::new(&config_manager).unwrap_or_else(|_| {
941 let temp_config = ConfigManager::new("datui").unwrap_or_else(|_| ConfigManager {
942 config_dir: std::env::temp_dir().join("datui").join("config"),
943 });
944 TemplateManager::new(&temp_config).unwrap_or_else(|_| {
945 let last_resort = ConfigManager {
946 config_dir: std::env::temp_dir().join("datui_config"),
947 };
948 TemplateManager::new(&last_resort)
949 .unwrap_or_else(|_| TemplateManager::empty(&last_resort))
950 })
951 });
952
953 App {
954 path: None,
955 data_table_state: None,
956 original_file_format: None,
957 original_file_delimiter: None,
958 events,
959 focus: 0,
960 debug: DebugState::default(),
961 info_modal: InfoModal::new(),
962 parquet_metadata_cache: None,
963 query_input: TextInput::new()
964 .with_history_limit(app_config.query.history_limit)
965 .with_theme(&theme)
966 .with_history("query".to_string()),
967 sql_input: TextInput::new()
968 .with_history_limit(app_config.query.history_limit)
969 .with_theme(&theme)
970 .with_history("sql".to_string()),
971 fuzzy_input: TextInput::new()
972 .with_history_limit(app_config.query.history_limit)
973 .with_theme(&theme)
974 .with_history("fuzzy".to_string()),
975 input_mode: InputMode::Normal,
976 input_type: None,
977 query_tab: QueryTab::SqlLike,
978 query_focus: QueryFocus::Input,
979 sort_filter_modal: SortFilterModal::new(),
980 pivot_melt_modal: PivotMeltModal::new(),
981 template_modal: TemplateModal::new(),
982 analysis_modal: AnalysisModal::new(),
983 chart_modal: ChartModal::new(),
984 chart_export_modal: ChartExportModal::new(),
985 export_modal: ExportModal::new(),
986 chart_cache: ChartCache::default(),
987 error_modal: ErrorModal::new(),
988 success_modal: SuccessModal::new(),
989 confirmation_modal: ConfirmationModal::new(),
990 pending_export: None,
991 export_df: None,
992 pending_chart_export: None,
993 #[cfg(any(feature = "http", feature = "cloud"))]
994 pending_download: None,
995 show_help: false,
996 help_scroll: 0,
997 cache,
998 template_manager,
999 active_template_id: None,
1000 loading_state: LoadingState::Idle,
1001 theme,
1002 sampling_threshold: app_config.performance.sampling_threshold,
1003 history_limit: app_config.query.history_limit,
1004 table_cell_padding: app_config.display.table_cell_padding.min(u16::MAX as usize) as u16,
1005 column_colors: app_config.display.column_colors,
1006 busy: false,
1007 throbber_frame: 0,
1008 drain_keys_on_next_loop: false,
1009 analysis_computation: None,
1010 app_config,
1011 #[cfg(feature = "http")]
1012 http_temp_path: None,
1013 }
1014 }
1015
1016 pub fn enable_debug(&mut self) {
1017 self.debug.enabled = true;
1018 }
1019
1020 fn color(&self, name: &str) -> Color {
1022 self.theme.get(name)
1023 }
1024
1025 fn load(&mut self, paths: &[PathBuf], options: &OpenOptions) -> Result<()> {
1026 self.parquet_metadata_cache = None;
1027 self.export_df = None;
1028 let path = &paths[0]; let compression = options
1031 .compression
1032 .or_else(|| CompressionFormat::from_extension(path));
1033 let is_csv = path
1034 .file_stem()
1035 .and_then(|stem| stem.to_str())
1036 .map(|stem| {
1037 stem.ends_with(".csv")
1038 || path
1039 .extension()
1040 .and_then(|e| e.to_str())
1041 .map(|e| e.eq_ignore_ascii_case("csv"))
1042 .unwrap_or(false)
1043 })
1044 .unwrap_or(false);
1045 let is_compressed_csv = paths.len() == 1 && compression.is_some() && is_csv;
1046
1047 if is_compressed_csv {
1050 if let LoadingState::Loading {
1052 file_path,
1053 file_size,
1054 ..
1055 } = &self.loading_state
1056 {
1057 self.loading_state = LoadingState::Loading {
1058 file_path: file_path.clone(),
1059 file_size: *file_size,
1060 current_phase: "Reading data".to_string(),
1061 progress_percent: 50,
1062 };
1063 }
1064 let lf = DataTableState::from_csv(path, options)?; if let LoadingState::Loading {
1068 file_path,
1069 file_size,
1070 ..
1071 } = &self.loading_state
1072 {
1073 self.loading_state = LoadingState::Loading {
1074 file_path: file_path.clone(),
1075 file_size: *file_size,
1076 current_phase: "Building lazyframe".to_string(),
1077 progress_percent: 60,
1078 };
1079 }
1080
1081 if let LoadingState::Loading {
1083 file_path,
1084 file_size,
1085 ..
1086 } = &self.loading_state
1087 {
1088 self.loading_state = LoadingState::Loading {
1089 file_path: file_path.clone(),
1090 file_size: *file_size,
1091 current_phase: "Loading buffer".to_string(),
1092 progress_percent: 70,
1093 };
1094 }
1095
1096 self.data_table_state = Some(lf);
1097 self.path = Some(path.clone());
1098 let original_format =
1099 path.file_stem()
1100 .and_then(|stem| stem.to_str())
1101 .and_then(|stem| {
1102 if stem.ends_with(".csv") {
1103 Some(ExportFormat::Csv)
1104 } else {
1105 None
1106 }
1107 });
1108 self.original_file_format = original_format;
1109 self.original_file_delimiter = Some(options.delimiter.unwrap_or(b','));
1110 self.sort_filter_modal = SortFilterModal::new();
1111 self.pivot_melt_modal = PivotMeltModal::new();
1112 return Ok(());
1113 }
1114
1115 if paths.len() == 1 && options.hive {
1118 let path_str = path.as_os_str().to_string_lossy();
1119 let is_single_file = path.exists()
1120 && path.is_file()
1121 && !path_str.contains('*')
1122 && !path_str.contains("**");
1123 if !is_single_file {
1124 let use_parquet_hive = path.is_dir()
1126 || path_str.contains(".parquet")
1127 || path_str.contains("*.parquet");
1128 if use_parquet_hive {
1129 if let LoadingState::Loading {
1130 file_path,
1131 file_size,
1132 ..
1133 } = &self.loading_state
1134 {
1135 self.loading_state = LoadingState::Loading {
1136 file_path: file_path.clone(),
1137 file_size: *file_size,
1138 current_phase: "Scanning partitioned dataset".to_string(),
1139 progress_percent: 60,
1140 };
1141 }
1142 let lf = DataTableState::from_parquet_hive(
1143 path,
1144 options.pages_lookahead,
1145 options.pages_lookback,
1146 options.max_buffered_rows,
1147 options.max_buffered_mb,
1148 options.row_numbers,
1149 options.row_start_index,
1150 )?;
1151 if let LoadingState::Loading {
1152 file_path,
1153 file_size,
1154 ..
1155 } = &self.loading_state
1156 {
1157 self.loading_state = LoadingState::Loading {
1158 file_path: file_path.clone(),
1159 file_size: *file_size,
1160 current_phase: "Rendering data".to_string(),
1161 progress_percent: 90,
1162 };
1163 }
1164 self.loading_state = LoadingState::Idle;
1165 self.data_table_state = Some(lf);
1166 self.path = Some(path.clone());
1167 self.original_file_format = Some(ExportFormat::Parquet);
1168 self.original_file_delimiter = None;
1169 self.sort_filter_modal = SortFilterModal::new();
1170 self.pivot_melt_modal = PivotMeltModal::new();
1171 return Ok(());
1172 }
1173 self.loading_state = LoadingState::Idle;
1174 return Err(color_eyre::eyre::eyre!(
1175 "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
1176 ));
1177 }
1178 }
1179
1180 if let LoadingState::Loading {
1183 file_path,
1184 file_size,
1185 ..
1186 } = &self.loading_state
1187 {
1188 self.loading_state = LoadingState::Loading {
1189 file_path: file_path.clone(),
1190 file_size: *file_size,
1191 current_phase: "Building lazyframe".to_string(),
1192 progress_percent: 60,
1193 };
1194 }
1195
1196 let original_format = path.extension().and_then(|e| e.to_str()).and_then(|ext| {
1198 if ext.eq_ignore_ascii_case("parquet") {
1199 Some(ExportFormat::Parquet)
1200 } else if ext.eq_ignore_ascii_case("csv") {
1201 Some(ExportFormat::Csv)
1202 } else if ext.eq_ignore_ascii_case("json") {
1203 Some(ExportFormat::Json)
1204 } else if ext.eq_ignore_ascii_case("jsonl") || ext.eq_ignore_ascii_case("ndjson") {
1205 Some(ExportFormat::Ndjson)
1206 } else if ext.eq_ignore_ascii_case("arrow")
1207 || ext.eq_ignore_ascii_case("ipc")
1208 || ext.eq_ignore_ascii_case("feather")
1209 {
1210 Some(ExportFormat::Ipc)
1211 } else if ext.eq_ignore_ascii_case("avro") {
1212 Some(ExportFormat::Avro)
1213 } else {
1214 None
1215 }
1216 });
1217
1218 let lf = if paths.len() > 1 {
1219 match path.extension() {
1221 Some(ext) if ext.eq_ignore_ascii_case("parquet") => {
1222 DataTableState::from_parquet_paths(
1223 paths,
1224 options.pages_lookahead,
1225 options.pages_lookback,
1226 options.max_buffered_rows,
1227 options.max_buffered_mb,
1228 options.row_numbers,
1229 options.row_start_index,
1230 )?
1231 }
1232 Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1233 DataTableState::from_csv_paths(paths, options)?
1234 }
1235 Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json_paths(
1236 paths,
1237 options.pages_lookahead,
1238 options.pages_lookback,
1239 options.max_buffered_rows,
1240 options.max_buffered_mb,
1241 options.row_numbers,
1242 options.row_start_index,
1243 )?,
1244 Some(ext) if ext.eq_ignore_ascii_case("jsonl") => {
1245 DataTableState::from_json_lines_paths(
1246 paths,
1247 options.pages_lookahead,
1248 options.pages_lookback,
1249 options.max_buffered_rows,
1250 options.max_buffered_mb,
1251 options.row_numbers,
1252 options.row_start_index,
1253 )?
1254 }
1255 Some(ext) if ext.eq_ignore_ascii_case("ndjson") => {
1256 DataTableState::from_ndjson_paths(
1257 paths,
1258 options.pages_lookahead,
1259 options.pages_lookback,
1260 options.max_buffered_rows,
1261 options.max_buffered_mb,
1262 options.row_numbers,
1263 options.row_start_index,
1264 )?
1265 }
1266 Some(ext)
1267 if ext.eq_ignore_ascii_case("arrow")
1268 || ext.eq_ignore_ascii_case("ipc")
1269 || ext.eq_ignore_ascii_case("feather") =>
1270 {
1271 DataTableState::from_ipc_paths(
1272 paths,
1273 options.pages_lookahead,
1274 options.pages_lookback,
1275 options.max_buffered_rows,
1276 options.max_buffered_mb,
1277 options.row_numbers,
1278 options.row_start_index,
1279 )?
1280 }
1281 Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro_paths(
1282 paths,
1283 options.pages_lookahead,
1284 options.pages_lookback,
1285 options.max_buffered_rows,
1286 options.max_buffered_mb,
1287 options.row_numbers,
1288 options.row_start_index,
1289 )?,
1290 Some(ext) if ext.eq_ignore_ascii_case("orc") => DataTableState::from_orc_paths(
1291 paths,
1292 options.pages_lookahead,
1293 options.pages_lookback,
1294 options.max_buffered_rows,
1295 options.max_buffered_mb,
1296 options.row_numbers,
1297 options.row_start_index,
1298 )?,
1299 _ => {
1300 self.loading_state = LoadingState::Idle;
1301 if !paths.is_empty() && !path.exists() {
1302 return Err(std::io::Error::new(
1303 std::io::ErrorKind::NotFound,
1304 format!("File not found: {}", path.display()),
1305 )
1306 .into());
1307 }
1308 return Err(color_eyre::eyre::eyre!(
1309 "Unsupported file type for multiple files (parquet, csv, json, jsonl, ndjson, arrow/ipc/feather, avro, orc only)"
1310 ));
1311 }
1312 }
1313 } else {
1314 match path.extension() {
1315 Some(ext) if ext.eq_ignore_ascii_case("parquet") => DataTableState::from_parquet(
1316 path,
1317 options.pages_lookahead,
1318 options.pages_lookback,
1319 options.max_buffered_rows,
1320 options.max_buffered_mb,
1321 options.row_numbers,
1322 options.row_start_index,
1323 )?,
1324 Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1325 DataTableState::from_csv(path, options)? }
1327 Some(ext) if ext.eq_ignore_ascii_case("tsv") => DataTableState::from_delimited(
1328 path,
1329 b'\t',
1330 options.pages_lookahead,
1331 options.pages_lookback,
1332 options.max_buffered_rows,
1333 options.max_buffered_mb,
1334 options.row_numbers,
1335 options.row_start_index,
1336 )?,
1337 Some(ext) if ext.eq_ignore_ascii_case("psv") => DataTableState::from_delimited(
1338 path,
1339 b'|',
1340 options.pages_lookahead,
1341 options.pages_lookback,
1342 options.max_buffered_rows,
1343 options.max_buffered_mb,
1344 options.row_numbers,
1345 options.row_start_index,
1346 )?,
1347 Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json(
1348 path,
1349 options.pages_lookahead,
1350 options.pages_lookback,
1351 options.max_buffered_rows,
1352 options.max_buffered_mb,
1353 options.row_numbers,
1354 options.row_start_index,
1355 )?,
1356 Some(ext) if ext.eq_ignore_ascii_case("jsonl") => DataTableState::from_json_lines(
1357 path,
1358 options.pages_lookahead,
1359 options.pages_lookback,
1360 options.max_buffered_rows,
1361 options.max_buffered_mb,
1362 options.row_numbers,
1363 options.row_start_index,
1364 )?,
1365 Some(ext) if ext.eq_ignore_ascii_case("ndjson") => DataTableState::from_ndjson(
1366 path,
1367 options.pages_lookahead,
1368 options.pages_lookback,
1369 options.max_buffered_rows,
1370 options.max_buffered_mb,
1371 options.row_numbers,
1372 options.row_start_index,
1373 )?,
1374 Some(ext)
1375 if ext.eq_ignore_ascii_case("arrow")
1376 || ext.eq_ignore_ascii_case("ipc")
1377 || ext.eq_ignore_ascii_case("feather") =>
1378 {
1379 DataTableState::from_ipc(
1380 path,
1381 options.pages_lookahead,
1382 options.pages_lookback,
1383 options.max_buffered_rows,
1384 options.max_buffered_mb,
1385 options.row_numbers,
1386 options.row_start_index,
1387 )?
1388 }
1389 Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro(
1390 path,
1391 options.pages_lookahead,
1392 options.pages_lookback,
1393 options.max_buffered_rows,
1394 options.max_buffered_mb,
1395 options.row_numbers,
1396 options.row_start_index,
1397 )?,
1398 Some(ext)
1399 if ext.eq_ignore_ascii_case("xls")
1400 || ext.eq_ignore_ascii_case("xlsx")
1401 || ext.eq_ignore_ascii_case("xlsm")
1402 || ext.eq_ignore_ascii_case("xlsb") =>
1403 {
1404 DataTableState::from_excel(
1405 path,
1406 options.pages_lookahead,
1407 options.pages_lookback,
1408 options.max_buffered_rows,
1409 options.max_buffered_mb,
1410 options.row_numbers,
1411 options.row_start_index,
1412 options.excel_sheet.as_deref(),
1413 )?
1414 }
1415 Some(ext) if ext.eq_ignore_ascii_case("orc") => DataTableState::from_orc(
1416 path,
1417 options.pages_lookahead,
1418 options.pages_lookback,
1419 options.max_buffered_rows,
1420 options.max_buffered_mb,
1421 options.row_numbers,
1422 options.row_start_index,
1423 )?,
1424 _ => {
1425 self.loading_state = LoadingState::Idle;
1426 if paths.len() == 1 && !path.exists() {
1427 return Err(std::io::Error::new(
1428 std::io::ErrorKind::NotFound,
1429 format!("File not found: {}", path.display()),
1430 )
1431 .into());
1432 }
1433 return Err(color_eyre::eyre::eyre!("Unsupported file type"));
1434 }
1435 }
1436 };
1437
1438 if let LoadingState::Loading {
1440 file_path,
1441 file_size,
1442 ..
1443 } = &self.loading_state
1444 {
1445 self.loading_state = LoadingState::Loading {
1446 file_path: file_path.clone(),
1447 file_size: *file_size,
1448 current_phase: "Rendering data".to_string(),
1449 progress_percent: 90,
1450 };
1451 }
1452
1453 self.loading_state = LoadingState::Idle;
1455 self.data_table_state = Some(lf);
1456 self.path = Some(path.clone());
1457 self.original_file_format = original_format;
1458 self.original_file_delimiter = match path.extension().and_then(|e| e.to_str()) {
1460 Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1461 Some(options.delimiter.unwrap_or(b','))
1463 }
1464 Some(ext) if ext.eq_ignore_ascii_case("tsv") => Some(b'\t'),
1465 Some(ext) if ext.eq_ignore_ascii_case("psv") => Some(b'|'),
1466 _ => None, };
1468 self.sort_filter_modal = SortFilterModal::new();
1469 self.pivot_melt_modal = PivotMeltModal::new();
1470 Ok(())
1471 }
1472
1473 #[cfg(feature = "cloud")]
1474 fn build_s3_cloud_options(
1475 cloud: &crate::config::CloudConfig,
1476 options: &OpenOptions,
1477 ) -> CloudOptions {
1478 let mut opts = CloudOptions::default();
1479 let mut configs: Vec<(AmazonS3ConfigKey, String)> = Vec::new();
1480 let e = options
1481 .s3_endpoint_url_override
1482 .as_ref()
1483 .or(cloud.s3_endpoint_url.as_ref());
1484 let k = options
1485 .s3_access_key_id_override
1486 .as_ref()
1487 .or(cloud.s3_access_key_id.as_ref());
1488 let s = options
1489 .s3_secret_access_key_override
1490 .as_ref()
1491 .or(cloud.s3_secret_access_key.as_ref());
1492 let r = options
1493 .s3_region_override
1494 .as_ref()
1495 .or(cloud.s3_region.as_ref());
1496 if let Some(e) = e {
1497 configs.push((AmazonS3ConfigKey::Endpoint, e.clone()));
1498 }
1499 if let Some(k) = k {
1500 configs.push((AmazonS3ConfigKey::AccessKeyId, k.clone()));
1501 }
1502 if let Some(s) = s {
1503 configs.push((AmazonS3ConfigKey::SecretAccessKey, s.clone()));
1504 }
1505 if let Some(r) = r {
1506 configs.push((AmazonS3ConfigKey::Region, r.clone()));
1507 }
1508 if !configs.is_empty() {
1509 opts = opts.with_aws(configs);
1510 }
1511 opts
1512 }
1513
1514 #[cfg(feature = "cloud")]
1515 fn build_s3_object_store(
1516 s3_url: &str,
1517 cloud: &crate::config::CloudConfig,
1518 options: &OpenOptions,
1519 ) -> Result<Arc<dyn object_store::ObjectStore>> {
1520 let (path_part, _ext) = source::url_path_extension(s3_url);
1521 let (bucket, _key) = path_part
1522 .split_once('/')
1523 .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1524 let mut builder = object_store::aws::AmazonS3Builder::from_env()
1525 .with_url(s3_url)
1526 .with_bucket_name(bucket);
1527 let e = options
1528 .s3_endpoint_url_override
1529 .as_ref()
1530 .or(cloud.s3_endpoint_url.as_ref());
1531 let k = options
1532 .s3_access_key_id_override
1533 .as_ref()
1534 .or(cloud.s3_access_key_id.as_ref());
1535 let s = options
1536 .s3_secret_access_key_override
1537 .as_ref()
1538 .or(cloud.s3_secret_access_key.as_ref());
1539 let r = options
1540 .s3_region_override
1541 .as_ref()
1542 .or(cloud.s3_region.as_ref());
1543 if let Some(e) = e {
1544 builder = builder.with_endpoint(e);
1545 }
1546 if let Some(k) = k {
1547 builder = builder.with_access_key_id(k);
1548 }
1549 if let Some(s) = s {
1550 builder = builder.with_secret_access_key(s);
1551 }
1552 if let Some(r) = r {
1553 builder = builder.with_region(r);
1554 }
1555 let store = builder
1556 .build()
1557 .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1558 Ok(Arc::new(store))
1559 }
1560
1561 #[cfg(feature = "cloud")]
1562 fn build_gcs_object_store(gs_url: &str) -> Result<Arc<dyn object_store::ObjectStore>> {
1563 let (path_part, _ext) = source::url_path_extension(gs_url);
1564 let (bucket, _key) = path_part
1565 .split_once('/')
1566 .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1567 let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1568 .with_url(gs_url)
1569 .with_bucket_name(bucket)
1570 .build()
1571 .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1572 Ok(Arc::new(store))
1573 }
1574
1575 fn format_bytes(n: u64) -> String {
1577 const KB: u64 = 1024;
1578 const MB: u64 = KB * 1024;
1579 const GB: u64 = MB * 1024;
1580 const TB: u64 = GB * 1024;
1581 if n >= TB {
1582 format!("{:.2} TB", n as f64 / TB as f64)
1583 } else if n >= GB {
1584 format!("{:.2} GB", n as f64 / GB as f64)
1585 } else if n >= MB {
1586 format!("{:.2} MB", n as f64 / MB as f64)
1587 } else if n >= KB {
1588 format!("{:.2} KB", n as f64 / KB as f64)
1589 } else {
1590 format!("{} bytes", n)
1591 }
1592 }
1593
1594 #[cfg(feature = "http")]
1595 fn fetch_remote_size_http(url: &str) -> Result<Option<u64>> {
1596 let response = ureq::request("HEAD", url)
1597 .timeout(std::time::Duration::from_secs(15))
1598 .call();
1599 match response {
1600 Ok(r) => Ok(r
1601 .header("Content-Length")
1602 .and_then(|s| s.parse::<u64>().ok())),
1603 Err(_) => Ok(None),
1604 }
1605 }
1606
1607 #[cfg(feature = "cloud")]
1608 fn fetch_remote_size_s3(
1609 s3_url: &str,
1610 cloud: &crate::config::CloudConfig,
1611 options: &OpenOptions,
1612 ) -> Result<Option<u64>> {
1613 use object_store::path::Path as OsPath;
1614 use object_store::ObjectStore;
1615
1616 let (path_part, _ext) = source::url_path_extension(s3_url);
1617 let (bucket, key) = path_part
1618 .split_once('/')
1619 .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1620 if key.is_empty() {
1621 return Ok(None);
1622 }
1623 let mut builder = object_store::aws::AmazonS3Builder::from_env()
1624 .with_url(s3_url)
1625 .with_bucket_name(bucket);
1626 let e = options
1627 .s3_endpoint_url_override
1628 .as_ref()
1629 .or(cloud.s3_endpoint_url.as_ref());
1630 let k = options
1631 .s3_access_key_id_override
1632 .as_ref()
1633 .or(cloud.s3_access_key_id.as_ref());
1634 let s = options
1635 .s3_secret_access_key_override
1636 .as_ref()
1637 .or(cloud.s3_secret_access_key.as_ref());
1638 let r = options
1639 .s3_region_override
1640 .as_ref()
1641 .or(cloud.s3_region.as_ref());
1642 if let Some(e) = e {
1643 builder = builder.with_endpoint(e);
1644 }
1645 if let Some(k) = k {
1646 builder = builder.with_access_key_id(k);
1647 }
1648 if let Some(s) = s {
1649 builder = builder.with_secret_access_key(s);
1650 }
1651 if let Some(r) = r {
1652 builder = builder.with_region(r);
1653 }
1654 let store = builder
1655 .build()
1656 .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1657 let rt = tokio::runtime::Runtime::new()
1658 .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1659 let path = OsPath::from(key);
1660 match rt.block_on(store.head(&path)) {
1661 Ok(meta) => Ok(Some(meta.size)),
1662 Err(_) => Ok(None),
1663 }
1664 }
1665
1666 #[cfg(feature = "cloud")]
1667 fn fetch_remote_size_gcs(gs_url: &str, _options: &OpenOptions) -> Result<Option<u64>> {
1668 use object_store::path::Path as OsPath;
1669 use object_store::ObjectStore;
1670
1671 let (path_part, _ext) = source::url_path_extension(gs_url);
1672 let (bucket, key) = path_part
1673 .split_once('/')
1674 .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1675 if key.is_empty() {
1676 return Ok(None);
1677 }
1678 let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1679 .with_url(gs_url)
1680 .with_bucket_name(bucket)
1681 .build()
1682 .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1683 let rt = tokio::runtime::Runtime::new()
1684 .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1685 let path = OsPath::from(key);
1686 match rt.block_on(store.head(&path)) {
1687 Ok(meta) => Ok(Some(meta.size)),
1688 Err(_) => Ok(None),
1689 }
1690 }
1691
1692 #[cfg(feature = "http")]
1693 fn download_http_to_temp(
1694 url: &str,
1695 temp_dir: Option<&Path>,
1696 extension: Option<&str>,
1697 ) -> Result<PathBuf> {
1698 let dir = temp_dir
1699 .map(Path::to_path_buf)
1700 .unwrap_or_else(std::env::temp_dir);
1701 let suffix = extension
1702 .map(|e| format!(".{e}"))
1703 .unwrap_or_else(|| ".tmp".to_string());
1704 let mut temp = tempfile::Builder::new()
1705 .suffix(&suffix)
1706 .tempfile_in(&dir)
1707 .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1708 let response = ureq::get(url)
1709 .timeout(std::time::Duration::from_secs(300))
1710 .call()
1711 .map_err(|e| {
1712 color_eyre::eyre::eyre!("Download failed. Check the URL and your connection: {}", e)
1713 })?;
1714 let status = response.status();
1715 if status >= 400 {
1716 return Err(color_eyre::eyre::eyre!(
1717 "Server returned {} {}. Check the URL.",
1718 status,
1719 response.status_text()
1720 ));
1721 }
1722 std::io::copy(&mut response.into_reader(), &mut temp)
1723 .map_err(|_| color_eyre::eyre::eyre!("Download failed while saving the file."))?;
1724 let (_file, path) = temp
1725 .keep()
1726 .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1727 Ok(path)
1728 }
1729
1730 #[cfg(feature = "cloud")]
1731 fn download_s3_to_temp(
1732 s3_url: &str,
1733 cloud: &crate::config::CloudConfig,
1734 options: &OpenOptions,
1735 ) -> Result<PathBuf> {
1736 use object_store::path::Path as OsPath;
1737 use object_store::ObjectStore;
1738
1739 let (path_part, ext) = source::url_path_extension(s3_url);
1740 let (bucket, key) = path_part
1741 .split_once('/')
1742 .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1743 if key.is_empty() {
1744 return Err(color_eyre::eyre::eyre!(
1745 "S3 URL must point to an object (e.g. s3://bucket/path/file.csv)"
1746 ));
1747 }
1748
1749 let mut builder = object_store::aws::AmazonS3Builder::from_env()
1750 .with_url(s3_url)
1751 .with_bucket_name(bucket);
1752 let e = options
1753 .s3_endpoint_url_override
1754 .as_ref()
1755 .or(cloud.s3_endpoint_url.as_ref());
1756 let k = options
1757 .s3_access_key_id_override
1758 .as_ref()
1759 .or(cloud.s3_access_key_id.as_ref());
1760 let s = options
1761 .s3_secret_access_key_override
1762 .as_ref()
1763 .or(cloud.s3_secret_access_key.as_ref());
1764 let r = options
1765 .s3_region_override
1766 .as_ref()
1767 .or(cloud.s3_region.as_ref());
1768 if let Some(e) = e {
1769 builder = builder.with_endpoint(e);
1770 }
1771 if let Some(k) = k {
1772 builder = builder.with_access_key_id(k);
1773 }
1774 if let Some(s) = s {
1775 builder = builder.with_secret_access_key(s);
1776 }
1777 if let Some(r) = r {
1778 builder = builder.with_region(r);
1779 }
1780 let store = builder
1781 .build()
1782 .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1783
1784 let rt = tokio::runtime::Runtime::new()
1785 .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1786 let path = OsPath::from(key);
1787 let get_result = rt.block_on(store.get(&path)).map_err(|e| {
1788 color_eyre::eyre::eyre!("Could not read from S3. Check credentials and URL: {}", e)
1789 })?;
1790 let bytes = rt
1791 .block_on(get_result.bytes())
1792 .map_err(|e| color_eyre::eyre::eyre!("Could not read S3 object body: {}", e))?;
1793
1794 let dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
1795 let suffix = ext
1796 .as_ref()
1797 .map(|e| format!(".{e}"))
1798 .unwrap_or_else(|| ".tmp".to_string());
1799 let mut temp = tempfile::Builder::new()
1800 .suffix(&suffix)
1801 .tempfile_in(&dir)
1802 .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1803 std::io::copy(&mut std::io::Cursor::new(bytes.as_ref()), &mut temp)
1804 .map_err(|_| color_eyre::eyre::eyre!("Could not write downloaded file."))?;
1805 let (_file, path_buf) = temp
1806 .keep()
1807 .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1808 Ok(path_buf)
1809 }
1810
1811 #[cfg(feature = "cloud")]
1812 fn download_gcs_to_temp(gs_url: &str, options: &OpenOptions) -> Result<PathBuf> {
1813 use object_store::path::Path as OsPath;
1814 use object_store::ObjectStore;
1815
1816 let (path_part, ext) = source::url_path_extension(gs_url);
1817 let (bucket, key) = path_part
1818 .split_once('/')
1819 .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1820 if key.is_empty() {
1821 return Err(color_eyre::eyre::eyre!(
1822 "GCS URL must point to an object (e.g. gs://bucket/path/file.csv)"
1823 ));
1824 }
1825
1826 let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1827 .with_url(gs_url)
1828 .with_bucket_name(bucket)
1829 .build()
1830 .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1831
1832 let rt = tokio::runtime::Runtime::new()
1833 .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1834 let path = OsPath::from(key);
1835 let get_result = rt.block_on(store.get(&path)).map_err(|e| {
1836 color_eyre::eyre::eyre!("Could not read from GCS. Check credentials and URL: {}", e)
1837 })?;
1838 let bytes = rt
1839 .block_on(get_result.bytes())
1840 .map_err(|e| color_eyre::eyre::eyre!("Could not read GCS object body: {}", e))?;
1841
1842 let dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
1843 let suffix = ext
1844 .as_ref()
1845 .map(|e| format!(".{e}"))
1846 .unwrap_or_else(|| ".tmp".to_string());
1847 let mut temp = tempfile::Builder::new()
1848 .suffix(&suffix)
1849 .tempfile_in(&dir)
1850 .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1851 std::io::copy(&mut std::io::Cursor::new(bytes.as_ref()), &mut temp)
1852 .map_err(|_| color_eyre::eyre::eyre!("Could not write downloaded file."))?;
1853 let (_file, path_buf) = temp
1854 .keep()
1855 .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1856 Ok(path_buf)
1857 }
1858
1859 fn build_lazyframe_from_paths(
1861 &mut self,
1862 paths: &[PathBuf],
1863 options: &OpenOptions,
1864 ) -> Result<LazyFrame> {
1865 let path = &paths[0];
1866 match source::input_source(path) {
1867 source::InputSource::Http(_url) => {
1868 #[cfg(feature = "http")]
1869 {
1870 return Err(color_eyre::eyre::eyre!(
1871 "HTTP/HTTPS load is handled in the event loop; this path should not be reached."
1872 ));
1873 }
1874 #[cfg(not(feature = "http"))]
1875 {
1876 return Err(color_eyre::eyre::eyre!(
1877 "HTTP/HTTPS URLs are not supported in this build. Rebuild with default features."
1878 ));
1879 }
1880 }
1881 source::InputSource::S3(url) => {
1882 #[cfg(feature = "cloud")]
1883 {
1884 let full = format!("s3://{url}");
1885 let cloud_opts = Self::build_s3_cloud_options(&self.app_config.cloud, options);
1886 let pl_path = PlPathRef::new(&full).into_owned();
1887 let is_glob = full.contains('*') || full.ends_with('/');
1888 let hive_options = if is_glob {
1889 polars::io::HiveOptions::new_enabled()
1890 } else {
1891 polars::io::HiveOptions::default()
1892 };
1893 let args = ScanArgsParquet {
1894 cloud_options: Some(cloud_opts),
1895 hive_options,
1896 glob: is_glob,
1897 ..Default::default()
1898 };
1899 let lf = LazyFrame::scan_parquet(pl_path, args).map_err(|e| {
1900 color_eyre::eyre::eyre!(
1901 "Could not read from S3. Check credentials and URL: {}",
1902 e
1903 )
1904 })?;
1905 let state = DataTableState::from_lazyframe(lf, options)?;
1906 return Ok(state.lf);
1907 }
1908 #[cfg(not(feature = "cloud"))]
1909 {
1910 return Err(color_eyre::eyre::eyre!(
1911 "S3 is not supported in this build. Rebuild with default features and set AWS credentials (e.g. AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)."
1912 ));
1913 }
1914 }
1915 source::InputSource::Gcs(url) => {
1916 #[cfg(feature = "cloud")]
1917 {
1918 let full = format!("gs://{url}");
1919 let pl_path = PlPathRef::new(&full).into_owned();
1920 let is_glob = full.contains('*') || full.ends_with('/');
1921 let hive_options = if is_glob {
1922 polars::io::HiveOptions::new_enabled()
1923 } else {
1924 polars::io::HiveOptions::default()
1925 };
1926 let args = ScanArgsParquet {
1927 cloud_options: Some(CloudOptions::default()),
1928 hive_options,
1929 glob: is_glob,
1930 ..Default::default()
1931 };
1932 let lf = LazyFrame::scan_parquet(pl_path, args).map_err(|e| {
1933 color_eyre::eyre::eyre!(
1934 "Could not read from GCS. Check credentials and URL: {}",
1935 e
1936 )
1937 })?;
1938 let state = DataTableState::from_lazyframe(lf, options)?;
1939 return Ok(state.lf);
1940 }
1941 #[cfg(not(feature = "cloud"))]
1942 {
1943 return Err(color_eyre::eyre::eyre!(
1944 "GCS (gs://) is not supported in this build. Rebuild with default features."
1945 ));
1946 }
1947 }
1948 source::InputSource::Local(_) => {}
1949 }
1950
1951 if paths.len() == 1 && options.hive {
1952 let path_str = path.as_os_str().to_string_lossy();
1953 let is_single_file = path.exists()
1954 && path.is_file()
1955 && !path_str.contains('*')
1956 && !path_str.contains("**");
1957 if !is_single_file {
1958 let use_parquet_hive = path.is_dir()
1959 || path_str.contains(".parquet")
1960 || path_str.contains("*.parquet");
1961 if use_parquet_hive {
1962 return DataTableState::scan_parquet_hive(path);
1964 }
1965 return Err(color_eyre::eyre::eyre!(
1966 "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
1967 ));
1968 }
1969 }
1970
1971 let lf = if paths.len() > 1 {
1972 match path.extension() {
1973 Some(ext) if ext.eq_ignore_ascii_case("parquet") => {
1974 DataTableState::from_parquet_paths(
1975 paths,
1976 options.pages_lookahead,
1977 options.pages_lookback,
1978 options.max_buffered_rows,
1979 options.max_buffered_mb,
1980 options.row_numbers,
1981 options.row_start_index,
1982 )?
1983 }
1984 Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1985 DataTableState::from_csv_paths(paths, options)?
1986 }
1987 Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json_paths(
1988 paths,
1989 options.pages_lookahead,
1990 options.pages_lookback,
1991 options.max_buffered_rows,
1992 options.max_buffered_mb,
1993 options.row_numbers,
1994 options.row_start_index,
1995 )?,
1996 Some(ext) if ext.eq_ignore_ascii_case("jsonl") => {
1997 DataTableState::from_json_lines_paths(
1998 paths,
1999 options.pages_lookahead,
2000 options.pages_lookback,
2001 options.max_buffered_rows,
2002 options.max_buffered_mb,
2003 options.row_numbers,
2004 options.row_start_index,
2005 )?
2006 }
2007 Some(ext) if ext.eq_ignore_ascii_case("ndjson") => {
2008 DataTableState::from_ndjson_paths(
2009 paths,
2010 options.pages_lookahead,
2011 options.pages_lookback,
2012 options.max_buffered_rows,
2013 options.max_buffered_mb,
2014 options.row_numbers,
2015 options.row_start_index,
2016 )?
2017 }
2018 Some(ext)
2019 if ext.eq_ignore_ascii_case("arrow")
2020 || ext.eq_ignore_ascii_case("ipc")
2021 || ext.eq_ignore_ascii_case("feather") =>
2022 {
2023 DataTableState::from_ipc_paths(
2024 paths,
2025 options.pages_lookahead,
2026 options.pages_lookback,
2027 options.max_buffered_rows,
2028 options.max_buffered_mb,
2029 options.row_numbers,
2030 options.row_start_index,
2031 )?
2032 }
2033 Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro_paths(
2034 paths,
2035 options.pages_lookahead,
2036 options.pages_lookback,
2037 options.max_buffered_rows,
2038 options.max_buffered_mb,
2039 options.row_numbers,
2040 options.row_start_index,
2041 )?,
2042 Some(ext) if ext.eq_ignore_ascii_case("orc") => DataTableState::from_orc_paths(
2043 paths,
2044 options.pages_lookahead,
2045 options.pages_lookback,
2046 options.max_buffered_rows,
2047 options.max_buffered_mb,
2048 options.row_numbers,
2049 options.row_start_index,
2050 )?,
2051 _ => {
2052 if !paths.is_empty() && !path.exists() {
2053 return Err(std::io::Error::new(
2054 std::io::ErrorKind::NotFound,
2055 format!("File not found: {}", path.display()),
2056 )
2057 .into());
2058 }
2059 return Err(color_eyre::eyre::eyre!(
2060 "Unsupported file type for multiple files (parquet, csv, json, jsonl, ndjson, arrow/ipc/feather, avro, orc only)"
2061 ));
2062 }
2063 }
2064 } else {
2065 match path.extension() {
2066 Some(ext) if ext.eq_ignore_ascii_case("parquet") => DataTableState::from_parquet(
2067 path,
2068 options.pages_lookahead,
2069 options.pages_lookback,
2070 options.max_buffered_rows,
2071 options.max_buffered_mb,
2072 options.row_numbers,
2073 options.row_start_index,
2074 )?,
2075 Some(ext) if ext.eq_ignore_ascii_case("csv") => {
2076 DataTableState::from_csv(path, options)?
2077 }
2078 Some(ext) if ext.eq_ignore_ascii_case("tsv") => DataTableState::from_delimited(
2079 path,
2080 b'\t',
2081 options.pages_lookahead,
2082 options.pages_lookback,
2083 options.max_buffered_rows,
2084 options.max_buffered_mb,
2085 options.row_numbers,
2086 options.row_start_index,
2087 )?,
2088 Some(ext) if ext.eq_ignore_ascii_case("psv") => DataTableState::from_delimited(
2089 path,
2090 b'|',
2091 options.pages_lookahead,
2092 options.pages_lookback,
2093 options.max_buffered_rows,
2094 options.max_buffered_mb,
2095 options.row_numbers,
2096 options.row_start_index,
2097 )?,
2098 Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json(
2099 path,
2100 options.pages_lookahead,
2101 options.pages_lookback,
2102 options.max_buffered_rows,
2103 options.max_buffered_mb,
2104 options.row_numbers,
2105 options.row_start_index,
2106 )?,
2107 Some(ext) if ext.eq_ignore_ascii_case("jsonl") => DataTableState::from_json_lines(
2108 path,
2109 options.pages_lookahead,
2110 options.pages_lookback,
2111 options.max_buffered_rows,
2112 options.max_buffered_mb,
2113 options.row_numbers,
2114 options.row_start_index,
2115 )?,
2116 Some(ext) if ext.eq_ignore_ascii_case("ndjson") => DataTableState::from_ndjson(
2117 path,
2118 options.pages_lookahead,
2119 options.pages_lookback,
2120 options.max_buffered_rows,
2121 options.max_buffered_mb,
2122 options.row_numbers,
2123 options.row_start_index,
2124 )?,
2125 Some(ext)
2126 if ext.eq_ignore_ascii_case("arrow")
2127 || ext.eq_ignore_ascii_case("ipc")
2128 || ext.eq_ignore_ascii_case("feather") =>
2129 {
2130 DataTableState::from_ipc(
2131 path,
2132 options.pages_lookahead,
2133 options.pages_lookback,
2134 options.max_buffered_rows,
2135 options.max_buffered_mb,
2136 options.row_numbers,
2137 options.row_start_index,
2138 )?
2139 }
2140 Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro(
2141 path,
2142 options.pages_lookahead,
2143 options.pages_lookback,
2144 options.max_buffered_rows,
2145 options.max_buffered_mb,
2146 options.row_numbers,
2147 options.row_start_index,
2148 )?,
2149 Some(ext)
2150 if ext.eq_ignore_ascii_case("xls")
2151 || ext.eq_ignore_ascii_case("xlsx")
2152 || ext.eq_ignore_ascii_case("xlsm")
2153 || ext.eq_ignore_ascii_case("xlsb") =>
2154 {
2155 DataTableState::from_excel(
2156 path,
2157 options.pages_lookahead,
2158 options.pages_lookback,
2159 options.max_buffered_rows,
2160 options.max_buffered_mb,
2161 options.row_numbers,
2162 options.row_start_index,
2163 options.excel_sheet.as_deref(),
2164 )?
2165 }
2166 Some(ext) if ext.eq_ignore_ascii_case("orc") => DataTableState::from_orc(
2167 path,
2168 options.pages_lookahead,
2169 options.pages_lookback,
2170 options.max_buffered_rows,
2171 options.max_buffered_mb,
2172 options.row_numbers,
2173 options.row_start_index,
2174 )?,
2175 _ => {
2176 if paths.len() == 1 && !path.exists() {
2177 return Err(std::io::Error::new(
2178 std::io::ErrorKind::NotFound,
2179 format!("File not found: {}", path.display()),
2180 )
2181 .into());
2182 }
2183 return Err(color_eyre::eyre::eyre!("Unsupported file type"));
2184 }
2185 }
2186 };
2187 Ok(lf.lf)
2188 }
2189
2190 fn open_help_overlay(&mut self) {
2192 let already = self.show_help
2193 || (self.template_modal.active && self.template_modal.show_help)
2194 || (self.analysis_modal.active && self.analysis_modal.show_help);
2195 if already {
2196 return;
2197 }
2198 if self.analysis_modal.active {
2199 self.analysis_modal.show_help = true;
2200 } else if self.template_modal.active {
2201 self.template_modal.show_help = true;
2202 } else {
2203 self.show_help = true;
2204 }
2205 }
2206
2207 fn key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
2208 self.debug.on_key(event);
2209
2210 if event.code == KeyCode::F(1) {
2212 self.open_help_overlay();
2213 return None;
2214 }
2215
2216 if self.confirmation_modal.active {
2219 match event.code {
2220 KeyCode::Left | KeyCode::Char('h') => {
2221 self.confirmation_modal.focus_yes = true;
2222 }
2223 KeyCode::Right | KeyCode::Char('l') => {
2224 self.confirmation_modal.focus_yes = false;
2225 }
2226 KeyCode::Tab => {
2227 self.confirmation_modal.focus_yes = !self.confirmation_modal.focus_yes;
2229 }
2230 KeyCode::Enter => {
2231 if self.confirmation_modal.focus_yes {
2232 if let Some((path, format, title)) = self.pending_chart_export.take() {
2234 self.confirmation_modal.hide();
2235 return Some(AppEvent::ChartExport(path, format, title));
2236 }
2237 if let Some((path, format, options)) = self.pending_export.take() {
2238 self.confirmation_modal.hide();
2239 return Some(AppEvent::Export(path, format, options));
2240 }
2241 #[cfg(any(feature = "http", feature = "cloud"))]
2242 if let Some(pending) = self.pending_download.take() {
2243 self.confirmation_modal.hide();
2244 if let LoadingState::Loading {
2245 file_path,
2246 file_size,
2247 ..
2248 } = &self.loading_state
2249 {
2250 self.loading_state = LoadingState::Loading {
2251 file_path: file_path.clone(),
2252 file_size: *file_size,
2253 current_phase: "Downloading".to_string(),
2254 progress_percent: 20,
2255 };
2256 }
2257 return Some(match pending {
2258 #[cfg(feature = "http")]
2259 PendingDownload::Http { url, options, .. } => {
2260 AppEvent::DoDownloadHttp(url, options)
2261 }
2262 #[cfg(feature = "cloud")]
2263 PendingDownload::S3 { url, options, .. } => {
2264 AppEvent::DoDownloadS3ToTemp(url, options)
2265 }
2266 #[cfg(feature = "cloud")]
2267 PendingDownload::Gcs { url, options, .. } => {
2268 AppEvent::DoDownloadGcsToTemp(url, options)
2269 }
2270 });
2271 }
2272 } else {
2273 if let Some((path, format, _)) = self.pending_chart_export.take() {
2275 self.chart_export_modal.reopen_with_path(&path, format);
2276 }
2277 self.pending_export = None;
2278 #[cfg(any(feature = "http", feature = "cloud"))]
2279 if self.pending_download.take().is_some() {
2280 self.confirmation_modal.hide();
2281 return Some(AppEvent::Exit);
2282 }
2283 self.confirmation_modal.hide();
2284 }
2285 }
2286 KeyCode::Esc => {
2287 if let Some((path, format, _)) = self.pending_chart_export.take() {
2289 self.chart_export_modal.reopen_with_path(&path, format);
2290 }
2291 self.pending_export = None;
2292 #[cfg(any(feature = "http", feature = "cloud"))]
2293 if self.pending_download.take().is_some() {
2294 self.confirmation_modal.hide();
2295 return Some(AppEvent::Exit);
2296 }
2297 self.confirmation_modal.hide();
2298 }
2299 _ => {}
2300 }
2301 return None;
2302 }
2303 if self.success_modal.active {
2305 match event.code {
2306 KeyCode::Esc | KeyCode::Enter => {
2307 self.success_modal.hide();
2308 }
2309 _ => {}
2310 }
2311 return None;
2312 }
2313 if self.error_modal.active {
2315 match event.code {
2316 KeyCode::Esc | KeyCode::Enter => {
2317 self.error_modal.hide();
2318 }
2319 _ => {}
2320 }
2321 return None;
2322 }
2323
2324 let in_main_table = !(self.input_mode != InputMode::Normal
2328 || self.show_help
2329 || self.template_modal.active
2330 || self.analysis_modal.active);
2331 if in_main_table {
2332 let did_scroll = match event.code {
2333 KeyCode::Right | KeyCode::Char('l') => {
2334 if let Some(ref mut state) = self.data_table_state {
2335 state.scroll_right();
2336 if self.debug.enabled {
2337 self.debug.last_action = "scroll_right".to_string();
2338 }
2339 true
2340 } else {
2341 false
2342 }
2343 }
2344 KeyCode::Left | KeyCode::Char('h') => {
2345 if let Some(ref mut state) = self.data_table_state {
2346 state.scroll_left();
2347 if self.debug.enabled {
2348 self.debug.last_action = "scroll_left".to_string();
2349 }
2350 true
2351 } else {
2352 false
2353 }
2354 }
2355 _ => false,
2356 };
2357 if did_scroll {
2358 return None;
2359 }
2360 }
2361
2362 if self.show_help
2363 || (self.template_modal.active && self.template_modal.show_help)
2364 || (self.analysis_modal.active && self.analysis_modal.show_help)
2365 {
2366 match event.code {
2367 KeyCode::Esc => {
2368 if self.analysis_modal.active && self.analysis_modal.show_help {
2369 self.analysis_modal.show_help = false;
2370 } else if self.template_modal.active && self.template_modal.show_help {
2371 self.template_modal.show_help = false;
2372 } else {
2373 self.show_help = false;
2374 }
2375 self.help_scroll = 0;
2376 }
2377 KeyCode::Char('?') => {
2378 if self.analysis_modal.active && self.analysis_modal.show_help {
2379 self.analysis_modal.show_help = false;
2380 } else if self.template_modal.active && self.template_modal.show_help {
2381 self.template_modal.show_help = false;
2382 } else {
2383 self.show_help = false;
2384 }
2385 self.help_scroll = 0;
2386 }
2387 KeyCode::Down | KeyCode::Char('j') => {
2388 self.help_scroll = self.help_scroll.saturating_add(1);
2389 }
2390 KeyCode::Up | KeyCode::Char('k') => {
2391 self.help_scroll = self.help_scroll.saturating_sub(1);
2392 }
2393 KeyCode::PageDown => {
2394 self.help_scroll = self.help_scroll.saturating_add(10);
2395 }
2396 KeyCode::PageUp => {
2397 self.help_scroll = self.help_scroll.saturating_sub(10);
2398 }
2399 KeyCode::Home => {
2400 self.help_scroll = 0;
2401 }
2402 KeyCode::End => {
2403 }
2405 _ => {}
2406 }
2407 return None;
2408 }
2409
2410 if event.code == KeyCode::Char('?') {
2411 let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
2412 let in_text_input = match self.input_mode {
2413 InputMode::Editing => true,
2414 InputMode::Export => matches!(
2415 self.export_modal.focus,
2416 ExportFocus::PathInput | ExportFocus::CsvDelimiter
2417 ),
2418 InputMode::SortFilter => {
2419 let on_body = self.sort_filter_modal.focus == SortFilterFocus::Body;
2420 let filter_tab = self.sort_filter_modal.active_tab == SortFilterTab::Filter;
2421 on_body
2422 && filter_tab
2423 && self.sort_filter_modal.filter.focus == FilterFocus::Value
2424 }
2425 InputMode::PivotMelt => matches!(
2426 self.pivot_melt_modal.focus,
2427 PivotMeltFocus::PivotFilter
2428 | PivotMeltFocus::MeltFilter
2429 | PivotMeltFocus::MeltPattern
2430 | PivotMeltFocus::MeltVarName
2431 | PivotMeltFocus::MeltValName
2432 ),
2433 InputMode::Info | InputMode::Chart => false,
2434 InputMode::Normal => {
2435 if self.template_modal.active
2436 && self.template_modal.mode != TemplateModalMode::List
2437 {
2438 matches!(
2439 self.template_modal.create_focus,
2440 CreateFocus::Name
2441 | CreateFocus::Description
2442 | CreateFocus::ExactPath
2443 | CreateFocus::RelativePath
2444 | CreateFocus::PathPattern
2445 | CreateFocus::FilenamePattern
2446 )
2447 } else {
2448 false
2449 }
2450 }
2451 };
2452 if ctrl_help || !in_text_input {
2454 self.open_help_overlay();
2455 return None;
2456 }
2457 }
2458
2459 if self.input_mode == InputMode::SortFilter {
2460 let on_tab_bar = self.sort_filter_modal.focus == SortFilterFocus::TabBar;
2461 let on_body = self.sort_filter_modal.focus == SortFilterFocus::Body;
2462 let on_apply = self.sort_filter_modal.focus == SortFilterFocus::Apply;
2463 let on_cancel = self.sort_filter_modal.focus == SortFilterFocus::Cancel;
2464 let on_clear = self.sort_filter_modal.focus == SortFilterFocus::Clear;
2465 let sort_tab = self.sort_filter_modal.active_tab == SortFilterTab::Sort;
2466 let filter_tab = self.sort_filter_modal.active_tab == SortFilterTab::Filter;
2467
2468 match event.code {
2469 KeyCode::Esc => {
2470 for col in &mut self.sort_filter_modal.sort.columns {
2471 col.is_to_be_locked = false;
2472 }
2473 self.sort_filter_modal.sort.has_unapplied_changes = false;
2474 self.sort_filter_modal.close();
2475 self.input_mode = InputMode::Normal;
2476 }
2477 KeyCode::Tab => self.sort_filter_modal.next_focus(),
2478 KeyCode::BackTab => self.sort_filter_modal.prev_focus(),
2479 KeyCode::Left | KeyCode::Char('h') if on_tab_bar => {
2480 self.sort_filter_modal.switch_tab();
2481 }
2482 KeyCode::Right | KeyCode::Char('l') if on_tab_bar => {
2483 self.sort_filter_modal.switch_tab();
2484 }
2485 KeyCode::Enter if event.modifiers.contains(KeyModifiers::CONTROL) && sort_tab => {
2486 let columns = self.sort_filter_modal.sort.get_sorted_columns();
2487 let column_order = self.sort_filter_modal.sort.get_column_order();
2488 let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2489 let ascending = self.sort_filter_modal.sort.ascending;
2490 self.sort_filter_modal.sort.has_unapplied_changes = false;
2491 self.sort_filter_modal.close();
2492 self.input_mode = InputMode::Normal;
2493 let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2494 return Some(AppEvent::Sort(columns, ascending));
2495 }
2496 KeyCode::Enter if on_apply => {
2497 if sort_tab {
2498 let columns = self.sort_filter_modal.sort.get_sorted_columns();
2499 let column_order = self.sort_filter_modal.sort.get_column_order();
2500 let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2501 let ascending = self.sort_filter_modal.sort.ascending;
2502 self.sort_filter_modal.sort.has_unapplied_changes = false;
2503 self.sort_filter_modal.close();
2504 self.input_mode = InputMode::Normal;
2505 let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2506 return Some(AppEvent::Sort(columns, ascending));
2507 } else {
2508 let statements = self.sort_filter_modal.filter.statements.clone();
2509 self.sort_filter_modal.close();
2510 self.input_mode = InputMode::Normal;
2511 return Some(AppEvent::Filter(statements));
2512 }
2513 }
2514 KeyCode::Enter if on_cancel => {
2515 for col in &mut self.sort_filter_modal.sort.columns {
2516 col.is_to_be_locked = false;
2517 }
2518 self.sort_filter_modal.sort.has_unapplied_changes = false;
2519 self.sort_filter_modal.close();
2520 self.input_mode = InputMode::Normal;
2521 }
2522 KeyCode::Enter if on_clear => {
2523 if sort_tab {
2524 self.sort_filter_modal.sort.clear_selection();
2525 } else {
2526 self.sort_filter_modal.filter.statements.clear();
2527 self.sort_filter_modal.filter.list_state.select(None);
2528 }
2529 }
2530 KeyCode::Char(' ')
2531 if on_body
2532 && sort_tab
2533 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2534 {
2535 self.sort_filter_modal.sort.toggle_selection();
2536 }
2537 KeyCode::Char(' ')
2538 if on_body
2539 && sort_tab
2540 && self.sort_filter_modal.sort.focus == SortFocus::Order =>
2541 {
2542 self.sort_filter_modal.sort.ascending = !self.sort_filter_modal.sort.ascending;
2543 self.sort_filter_modal.sort.has_unapplied_changes = true;
2544 }
2545 KeyCode::Char(' ') if on_apply && sort_tab => {
2546 let columns = self.sort_filter_modal.sort.get_sorted_columns();
2547 let column_order = self.sort_filter_modal.sort.get_column_order();
2548 let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2549 let ascending = self.sort_filter_modal.sort.ascending;
2550 self.sort_filter_modal.sort.has_unapplied_changes = false;
2551 let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2552 return Some(AppEvent::Sort(columns, ascending));
2553 }
2554 KeyCode::Enter if on_body && filter_tab => {
2555 match self.sort_filter_modal.filter.focus {
2556 FilterFocus::Add => {
2557 self.sort_filter_modal.filter.add_statement();
2558 }
2559 FilterFocus::Statements => {
2560 let m = &mut self.sort_filter_modal.filter;
2561 if let Some(idx) = m.list_state.selected() {
2562 if idx < m.statements.len() {
2563 m.statements.remove(idx);
2564 if m.statements.is_empty() {
2565 m.list_state.select(None);
2566 m.focus = FilterFocus::Column;
2567 } else {
2568 m.list_state
2569 .select(Some(m.statements.len().saturating_sub(1)));
2570 }
2571 }
2572 }
2573 }
2574 _ => {}
2575 }
2576 }
2577 KeyCode::Enter if on_body && sort_tab => match self.sort_filter_modal.sort.focus {
2578 SortFocus::Filter => {
2579 self.sort_filter_modal.sort.focus = SortFocus::ColumnList;
2580 }
2581 SortFocus::ColumnList => {
2582 self.sort_filter_modal.sort.toggle_selection();
2583 let columns = self.sort_filter_modal.sort.get_sorted_columns();
2584 let column_order = self.sort_filter_modal.sort.get_column_order();
2585 let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2586 let ascending = self.sort_filter_modal.sort.ascending;
2587 self.sort_filter_modal.sort.has_unapplied_changes = false;
2588 let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2589 return Some(AppEvent::Sort(columns, ascending));
2590 }
2591 SortFocus::Order => {
2592 self.sort_filter_modal.sort.ascending =
2593 !self.sort_filter_modal.sort.ascending;
2594 self.sort_filter_modal.sort.has_unapplied_changes = true;
2595 }
2596 _ => {}
2597 },
2598 KeyCode::Left
2599 | KeyCode::Right
2600 | KeyCode::Char('h')
2601 | KeyCode::Char('l')
2602 | KeyCode::Up
2603 | KeyCode::Down
2604 | KeyCode::Char('j')
2605 | KeyCode::Char('k')
2606 if on_body
2607 && sort_tab
2608 && self.sort_filter_modal.sort.focus == SortFocus::Order =>
2609 {
2610 let s = &mut self.sort_filter_modal.sort;
2611 match event.code {
2612 KeyCode::Left | KeyCode::Char('h') | KeyCode::Up | KeyCode::Char('k') => {
2613 s.ascending = true;
2614 }
2615 KeyCode::Right
2616 | KeyCode::Char('l')
2617 | KeyCode::Down
2618 | KeyCode::Char('j') => {
2619 s.ascending = false;
2620 }
2621 _ => {}
2622 }
2623 s.has_unapplied_changes = true;
2624 }
2625 KeyCode::Down
2626 if on_body
2627 && filter_tab
2628 && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2629 {
2630 let m = &mut self.sort_filter_modal.filter;
2631 let i = match m.list_state.selected() {
2632 Some(i) => {
2633 if i >= m.statements.len().saturating_sub(1) {
2634 0
2635 } else {
2636 i + 1
2637 }
2638 }
2639 None => 0,
2640 };
2641 m.list_state.select(Some(i));
2642 }
2643 KeyCode::Up
2644 if on_body
2645 && filter_tab
2646 && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2647 {
2648 let m = &mut self.sort_filter_modal.filter;
2649 let i = match m.list_state.selected() {
2650 Some(i) => {
2651 if i == 0 {
2652 m.statements.len().saturating_sub(1)
2653 } else {
2654 i - 1
2655 }
2656 }
2657 None => 0,
2658 };
2659 m.list_state.select(Some(i));
2660 }
2661 KeyCode::Down | KeyCode::Char('j') if on_body && sort_tab => {
2662 let s = &mut self.sort_filter_modal.sort;
2663 if s.focus == SortFocus::ColumnList {
2664 let i = match s.table_state.selected() {
2665 Some(i) => {
2666 if i >= s.filtered_columns().len().saturating_sub(1) {
2667 0
2668 } else {
2669 i + 1
2670 }
2671 }
2672 None => 0,
2673 };
2674 s.table_state.select(Some(i));
2675 } else {
2676 let _ = s.next_body_focus();
2677 }
2678 }
2679 KeyCode::Up | KeyCode::Char('k') if on_body && sort_tab => {
2680 let s = &mut self.sort_filter_modal.sort;
2681 if s.focus == SortFocus::ColumnList {
2682 let i = match s.table_state.selected() {
2683 Some(i) => {
2684 if i == 0 {
2685 s.filtered_columns().len().saturating_sub(1)
2686 } else {
2687 i - 1
2688 }
2689 }
2690 None => 0,
2691 };
2692 s.table_state.select(Some(i));
2693 } else {
2694 let _ = s.prev_body_focus();
2695 }
2696 }
2697 KeyCode::Char(']')
2698 if on_body
2699 && sort_tab
2700 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2701 {
2702 self.sort_filter_modal.sort.move_selection_down();
2703 }
2704 KeyCode::Char('[')
2705 if on_body
2706 && sort_tab
2707 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2708 {
2709 self.sort_filter_modal.sort.move_selection_up();
2710 }
2711 KeyCode::Char('+') | KeyCode::Char('=')
2712 if on_body
2713 && sort_tab
2714 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2715 {
2716 self.sort_filter_modal.sort.move_column_display_up();
2717 self.sort_filter_modal.sort.has_unapplied_changes = true;
2718 }
2719 KeyCode::Char('-') | KeyCode::Char('_')
2720 if on_body
2721 && sort_tab
2722 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2723 {
2724 self.sort_filter_modal.sort.move_column_display_down();
2725 self.sort_filter_modal.sort.has_unapplied_changes = true;
2726 }
2727 KeyCode::Char('L')
2728 if on_body
2729 && sort_tab
2730 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2731 {
2732 self.sort_filter_modal.sort.toggle_lock_at_column();
2733 self.sort_filter_modal.sort.has_unapplied_changes = true;
2734 }
2735 KeyCode::Char('v')
2736 if on_body
2737 && sort_tab
2738 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2739 {
2740 self.sort_filter_modal.sort.toggle_visibility();
2741 self.sort_filter_modal.sort.has_unapplied_changes = true;
2742 }
2743 KeyCode::Char(c)
2744 if on_body
2745 && sort_tab
2746 && self.sort_filter_modal.sort.focus == SortFocus::ColumnList
2747 && c.is_ascii_digit() =>
2748 {
2749 if let Some(digit) = c.to_digit(10) {
2750 self.sort_filter_modal
2751 .sort
2752 .jump_selection_to_order(digit as usize);
2753 }
2754 }
2755 _ if on_body
2759 && sort_tab
2760 && self.sort_filter_modal.sort.focus == SortFocus::Filter
2761 && !matches!(
2762 event.code,
2763 KeyCode::Tab
2764 | KeyCode::BackTab
2765 | KeyCode::Esc
2766 | KeyCode::Enter
2767 | KeyCode::Up
2768 | KeyCode::Down
2769 ) =>
2770 {
2771 let _ = self
2773 .sort_filter_modal
2774 .sort
2775 .filter_input
2776 .handle_key(event, Some(&self.cache));
2777 }
2778 KeyCode::Char(c)
2779 if on_body
2780 && filter_tab
2781 && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2782 {
2783 self.sort_filter_modal.filter.new_value.push(c);
2784 }
2785 KeyCode::Backspace
2786 if on_body
2787 && filter_tab
2788 && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2789 {
2790 self.sort_filter_modal.filter.new_value.pop();
2791 }
2792 KeyCode::Right | KeyCode::Char('l') if on_body && filter_tab => {
2793 let m = &mut self.sort_filter_modal.filter;
2794 match m.focus {
2795 FilterFocus::Column => {
2796 m.new_column_idx =
2797 (m.new_column_idx + 1) % m.available_columns.len().max(1);
2798 }
2799 FilterFocus::Operator => {
2800 m.new_operator_idx =
2801 (m.new_operator_idx + 1) % FilterOperator::iterator().count();
2802 }
2803 FilterFocus::Logical => {
2804 m.new_logical_idx =
2805 (m.new_logical_idx + 1) % LogicalOperator::iterator().count();
2806 }
2807 _ => {}
2808 }
2809 }
2810 KeyCode::Left | KeyCode::Char('h') if on_body && filter_tab => {
2811 let m = &mut self.sort_filter_modal.filter;
2812 match m.focus {
2813 FilterFocus::Column => {
2814 m.new_column_idx = if m.new_column_idx == 0 {
2815 m.available_columns.len().saturating_sub(1)
2816 } else {
2817 m.new_column_idx - 1
2818 };
2819 }
2820 FilterFocus::Operator => {
2821 m.new_operator_idx = if m.new_operator_idx == 0 {
2822 FilterOperator::iterator().count() - 1
2823 } else {
2824 m.new_operator_idx - 1
2825 };
2826 }
2827 FilterFocus::Logical => {
2828 m.new_logical_idx = if m.new_logical_idx == 0 {
2829 LogicalOperator::iterator().count() - 1
2830 } else {
2831 m.new_logical_idx - 1
2832 };
2833 }
2834 _ => {}
2835 }
2836 }
2837 _ => {}
2838 }
2839 return None;
2840 }
2841
2842 if self.input_mode == InputMode::Export {
2843 match event.code {
2844 KeyCode::Esc => {
2845 self.export_modal.close();
2846 self.input_mode = InputMode::Normal;
2847 }
2848 KeyCode::Tab => self.export_modal.next_focus(),
2849 KeyCode::BackTab => self.export_modal.prev_focus(),
2850 KeyCode::Up | KeyCode::Char('k') => {
2851 match self.export_modal.focus {
2852 ExportFocus::FormatSelector => {
2853 let current_idx = ExportFormat::ALL
2855 .iter()
2856 .position(|&f| f == self.export_modal.selected_format)
2857 .unwrap_or(0);
2858 let prev_idx = if current_idx == 0 {
2859 ExportFormat::ALL.len() - 1
2860 } else {
2861 current_idx - 1
2862 };
2863 self.export_modal.selected_format = ExportFormat::ALL[prev_idx];
2864 }
2865 ExportFocus::PathInput => {
2866 self.export_modal.path_input.handle_key(event, None);
2868 }
2869 ExportFocus::CsvDelimiter => {
2870 self.export_modal
2872 .csv_delimiter_input
2873 .handle_key(event, None);
2874 }
2875 ExportFocus::CsvCompression
2876 | ExportFocus::JsonCompression
2877 | ExportFocus::NdjsonCompression => {
2878 self.export_modal.cycle_compression_backward();
2880 }
2881 _ => {
2882 self.export_modal.prev_focus();
2883 }
2884 }
2885 }
2886 KeyCode::Down | KeyCode::Char('j') => {
2887 match self.export_modal.focus {
2888 ExportFocus::FormatSelector => {
2889 let current_idx = ExportFormat::ALL
2891 .iter()
2892 .position(|&f| f == self.export_modal.selected_format)
2893 .unwrap_or(0);
2894 let next_idx = (current_idx + 1) % ExportFormat::ALL.len();
2895 self.export_modal.selected_format = ExportFormat::ALL[next_idx];
2896 }
2897 ExportFocus::PathInput => {
2898 self.export_modal.path_input.handle_key(event, None);
2900 }
2901 ExportFocus::CsvDelimiter => {
2902 self.export_modal
2904 .csv_delimiter_input
2905 .handle_key(event, None);
2906 }
2907 ExportFocus::CsvCompression
2908 | ExportFocus::JsonCompression
2909 | ExportFocus::NdjsonCompression => {
2910 self.export_modal.cycle_compression();
2912 }
2913 _ => {
2914 self.export_modal.next_focus();
2915 }
2916 }
2917 }
2918 KeyCode::Left | KeyCode::Char('h') => {
2919 match self.export_modal.focus {
2920 ExportFocus::PathInput => {
2921 self.export_modal.path_input.handle_key(event, None);
2922 }
2923 ExportFocus::CsvDelimiter => {
2924 self.export_modal
2925 .csv_delimiter_input
2926 .handle_key(event, None);
2927 }
2928 ExportFocus::FormatSelector => {
2929 }
2931 ExportFocus::CsvCompression
2932 | ExportFocus::JsonCompression
2933 | ExportFocus::NdjsonCompression => {
2934 self.export_modal.cycle_compression_backward();
2936 }
2937 _ => self.export_modal.prev_focus(),
2938 }
2939 }
2940 KeyCode::Right | KeyCode::Char('l') => {
2941 match self.export_modal.focus {
2942 ExportFocus::PathInput => {
2943 self.export_modal.path_input.handle_key(event, None);
2944 }
2945 ExportFocus::CsvDelimiter => {
2946 self.export_modal
2947 .csv_delimiter_input
2948 .handle_key(event, None);
2949 }
2950 ExportFocus::FormatSelector => {
2951 }
2953 ExportFocus::CsvCompression
2954 | ExportFocus::JsonCompression
2955 | ExportFocus::NdjsonCompression => {
2956 self.export_modal.cycle_compression();
2958 }
2959 _ => self.export_modal.next_focus(),
2960 }
2961 }
2962 KeyCode::Enter => {
2963 match self.export_modal.focus {
2964 ExportFocus::PathInput => {
2965 let path_str = self.export_modal.path_input.value.trim();
2967 if !path_str.is_empty() {
2968 let mut path = PathBuf::from(path_str);
2969 let format = self.export_modal.selected_format;
2970 let compression = match format {
2972 ExportFormat::Csv => self.export_modal.csv_compression,
2973 ExportFormat::Json => self.export_modal.json_compression,
2974 ExportFormat::Ndjson => self.export_modal.ndjson_compression,
2975 ExportFormat::Parquet
2976 | ExportFormat::Ipc
2977 | ExportFormat::Avro => None,
2978 };
2979 let path_with_ext =
2981 Self::ensure_file_extension(&path, format, compression);
2982 if path_with_ext != path {
2984 self.export_modal
2985 .path_input
2986 .set_value(path_with_ext.display().to_string());
2987 }
2988 path = path_with_ext;
2989 let delimiter =
2990 self.export_modal
2991 .csv_delimiter_input
2992 .value
2993 .chars()
2994 .next()
2995 .unwrap_or(',') as u8;
2996 let options = ExportOptions {
2997 csv_delimiter: delimiter,
2998 csv_include_header: self.export_modal.csv_include_header,
2999 csv_compression: self.export_modal.csv_compression,
3000 json_compression: self.export_modal.json_compression,
3001 ndjson_compression: self.export_modal.ndjson_compression,
3002 parquet_compression: None,
3003 };
3004 if path.exists() {
3006 let path_display = path.display().to_string();
3007 self.pending_export = Some((path, format, options));
3008 self.confirmation_modal.show(format!(
3009 "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3010 path_display
3011 ));
3012 self.export_modal.close();
3013 self.input_mode = InputMode::Normal;
3014 } else {
3015 self.export_modal.close();
3017 self.input_mode = InputMode::Normal;
3018 return Some(AppEvent::Export(path, format, options));
3019 }
3020 }
3021 }
3022 ExportFocus::ExportButton => {
3023 if !self.export_modal.path_input.value.is_empty() {
3024 let mut path = PathBuf::from(&self.export_modal.path_input.value);
3025 let format = self.export_modal.selected_format;
3026 let compression = match format {
3028 ExportFormat::Csv => self.export_modal.csv_compression,
3029 ExportFormat::Json => self.export_modal.json_compression,
3030 ExportFormat::Ndjson => self.export_modal.ndjson_compression,
3031 ExportFormat::Parquet
3032 | ExportFormat::Ipc
3033 | ExportFormat::Avro => None,
3034 };
3035 let path_with_ext =
3037 Self::ensure_file_extension(&path, format, compression);
3038 if path_with_ext != path {
3040 self.export_modal
3041 .path_input
3042 .set_value(path_with_ext.display().to_string());
3043 }
3044 path = path_with_ext;
3045 let delimiter =
3046 self.export_modal
3047 .csv_delimiter_input
3048 .value
3049 .chars()
3050 .next()
3051 .unwrap_or(',') as u8;
3052 let options = ExportOptions {
3053 csv_delimiter: delimiter,
3054 csv_include_header: self.export_modal.csv_include_header,
3055 csv_compression: self.export_modal.csv_compression,
3056 json_compression: self.export_modal.json_compression,
3057 ndjson_compression: self.export_modal.ndjson_compression,
3058 parquet_compression: None,
3059 };
3060 if path.exists() {
3062 let path_display = path.display().to_string();
3063 self.pending_export = Some((path, format, options));
3064 self.confirmation_modal.show(format!(
3065 "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3066 path_display
3067 ));
3068 self.export_modal.close();
3069 self.input_mode = InputMode::Normal;
3070 } else {
3071 self.export_modal.close();
3073 self.input_mode = InputMode::Normal;
3074 return Some(AppEvent::Export(path, format, options));
3075 }
3076 }
3077 }
3078 ExportFocus::CancelButton => {
3079 self.export_modal.close();
3080 self.input_mode = InputMode::Normal;
3081 }
3082 ExportFocus::CsvIncludeHeader => {
3083 self.export_modal.csv_include_header =
3084 !self.export_modal.csv_include_header;
3085 }
3086 ExportFocus::CsvCompression
3087 | ExportFocus::JsonCompression
3088 | ExportFocus::NdjsonCompression => {
3089 }
3092 _ => {}
3093 }
3094 }
3095 KeyCode::Char(' ') => {
3096 match self.export_modal.focus {
3098 ExportFocus::PathInput => {
3099 self.export_modal.path_input.handle_key(event, None);
3101 }
3102 ExportFocus::CsvDelimiter => {
3103 self.export_modal
3105 .csv_delimiter_input
3106 .handle_key(event, None);
3107 }
3108 ExportFocus::CsvIncludeHeader => {
3109 self.export_modal.csv_include_header =
3111 !self.export_modal.csv_include_header;
3112 }
3113 _ => {}
3114 }
3115 }
3116 KeyCode::Char(_)
3117 | KeyCode::Backspace
3118 | KeyCode::Delete
3119 | KeyCode::Home
3120 | KeyCode::End => {
3121 match self.export_modal.focus {
3122 ExportFocus::PathInput => {
3123 self.export_modal.path_input.handle_key(event, None);
3124 }
3125 ExportFocus::CsvDelimiter => {
3126 self.export_modal
3127 .csv_delimiter_input
3128 .handle_key(event, None);
3129 }
3130 ExportFocus::FormatSelector => {
3131 }
3133 _ => {}
3134 }
3135 }
3136 _ => {}
3137 }
3138 return None;
3139 }
3140
3141 if self.input_mode == InputMode::PivotMelt {
3142 let pivot_melt_text_focus = matches!(
3143 self.pivot_melt_modal.focus,
3144 PivotMeltFocus::PivotFilter
3145 | PivotMeltFocus::MeltFilter
3146 | PivotMeltFocus::MeltPattern
3147 | PivotMeltFocus::MeltVarName
3148 | PivotMeltFocus::MeltValName
3149 );
3150 let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
3151 if event.code == KeyCode::Char('?') && (ctrl_help || !pivot_melt_text_focus) {
3152 self.show_help = true;
3153 return None;
3154 }
3155 match event.code {
3156 KeyCode::Esc => {
3157 self.pivot_melt_modal.close();
3158 self.input_mode = InputMode::Normal;
3159 }
3160 KeyCode::Tab => self.pivot_melt_modal.next_focus(),
3161 KeyCode::BackTab => self.pivot_melt_modal.prev_focus(),
3162 KeyCode::Left => {
3163 if self.pivot_melt_modal.focus == PivotMeltFocus::PivotAggregation {
3164 self.pivot_melt_modal.pivot_move_aggregation_step(4, 0, -1);
3165 } else if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3166 self.pivot_melt_modal
3167 .pivot_filter_input
3168 .handle_key(event, None);
3169 self.pivot_melt_modal.pivot_index_table.select(None);
3170 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter {
3171 self.pivot_melt_modal
3172 .melt_filter_input
3173 .handle_key(event, None);
3174 self.pivot_melt_modal.melt_index_table.select(None);
3175 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern
3176 && self.pivot_melt_modal.melt_pattern_cursor > 0
3177 {
3178 self.pivot_melt_modal.melt_pattern_cursor -= 1;
3179 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName
3180 && self.pivot_melt_modal.melt_variable_cursor > 0
3181 {
3182 self.pivot_melt_modal.melt_variable_cursor -= 1;
3183 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName
3184 && self.pivot_melt_modal.melt_value_cursor > 0
3185 {
3186 self.pivot_melt_modal.melt_value_cursor -= 1;
3187 } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3188 self.pivot_melt_modal.switch_tab();
3189 } else {
3190 self.pivot_melt_modal.prev_focus();
3191 }
3192 }
3193 KeyCode::Right => {
3194 if self.pivot_melt_modal.focus == PivotMeltFocus::PivotAggregation {
3195 self.pivot_melt_modal.pivot_move_aggregation_step(4, 0, 1);
3196 } else if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3197 self.pivot_melt_modal
3198 .pivot_filter_input
3199 .handle_key(event, None);
3200 self.pivot_melt_modal.pivot_index_table.select(None);
3201 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter {
3202 self.pivot_melt_modal
3203 .melt_filter_input
3204 .handle_key(event, None);
3205 self.pivot_melt_modal.melt_index_table.select(None);
3206 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern {
3207 let n = self.pivot_melt_modal.melt_pattern.chars().count();
3208 if self.pivot_melt_modal.melt_pattern_cursor < n {
3209 self.pivot_melt_modal.melt_pattern_cursor += 1;
3210 }
3211 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName {
3212 let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3213 if self.pivot_melt_modal.melt_variable_cursor < n {
3214 self.pivot_melt_modal.melt_variable_cursor += 1;
3215 }
3216 } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName {
3217 let n = self.pivot_melt_modal.melt_value_name.chars().count();
3218 if self.pivot_melt_modal.melt_value_cursor < n {
3219 self.pivot_melt_modal.melt_value_cursor += 1;
3220 }
3221 } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3222 self.pivot_melt_modal.switch_tab();
3223 } else {
3224 self.pivot_melt_modal.next_focus();
3225 }
3226 }
3227 KeyCode::Enter => match self.pivot_melt_modal.focus {
3228 PivotMeltFocus::Apply => {
3229 return match self.pivot_melt_modal.active_tab {
3230 PivotMeltTab::Pivot => {
3231 if let Some(err) = self.pivot_melt_modal.pivot_validation_error() {
3232 self.error_modal.show(err);
3233 None
3234 } else {
3235 self.pivot_melt_modal
3236 .build_pivot_spec()
3237 .map(AppEvent::Pivot)
3238 }
3239 }
3240 PivotMeltTab::Melt => {
3241 if let Some(err) = self.pivot_melt_modal.melt_validation_error() {
3242 self.error_modal.show(err);
3243 None
3244 } else {
3245 self.pivot_melt_modal.build_melt_spec().map(AppEvent::Melt)
3246 }
3247 }
3248 };
3249 }
3250 PivotMeltFocus::Cancel => {
3251 self.pivot_melt_modal.close();
3252 self.input_mode = InputMode::Normal;
3253 }
3254 PivotMeltFocus::Clear => {
3255 self.pivot_melt_modal.reset_form();
3256 }
3257 _ => {}
3258 },
3259 KeyCode::Up | KeyCode::Char('k') if !pivot_melt_text_focus => {
3260 match self.pivot_melt_modal.focus {
3261 PivotMeltFocus::PivotIndexList => {
3262 self.pivot_melt_modal.pivot_move_index_selection(false);
3263 }
3264 PivotMeltFocus::PivotPivotCol => {
3265 self.pivot_melt_modal.pivot_move_pivot_selection(false);
3266 }
3267 PivotMeltFocus::PivotValueCol => {
3268 self.pivot_melt_modal.pivot_move_value_selection(false);
3269 }
3270 PivotMeltFocus::PivotAggregation => {
3271 self.pivot_melt_modal.pivot_move_aggregation_step(4, -1, 0);
3272 }
3273 PivotMeltFocus::MeltIndexList => {
3274 self.pivot_melt_modal.melt_move_index_selection(false);
3275 }
3276 PivotMeltFocus::MeltStrategy => {
3277 self.pivot_melt_modal.melt_move_strategy(false);
3278 }
3279 PivotMeltFocus::MeltType => {
3280 self.pivot_melt_modal.melt_move_type_filter(false);
3281 }
3282 PivotMeltFocus::MeltExplicitList => {
3283 self.pivot_melt_modal.melt_move_explicit_selection(false);
3284 }
3285 _ => {}
3286 }
3287 }
3288 KeyCode::Down | KeyCode::Char('j') if !pivot_melt_text_focus => {
3289 match self.pivot_melt_modal.focus {
3290 PivotMeltFocus::PivotIndexList => {
3291 self.pivot_melt_modal.pivot_move_index_selection(true);
3292 }
3293 PivotMeltFocus::PivotPivotCol => {
3294 self.pivot_melt_modal.pivot_move_pivot_selection(true);
3295 }
3296 PivotMeltFocus::PivotValueCol => {
3297 self.pivot_melt_modal.pivot_move_value_selection(true);
3298 }
3299 PivotMeltFocus::PivotAggregation => {
3300 self.pivot_melt_modal.pivot_move_aggregation_step(4, 1, 0);
3301 }
3302 PivotMeltFocus::MeltIndexList => {
3303 self.pivot_melt_modal.melt_move_index_selection(true);
3304 }
3305 PivotMeltFocus::MeltStrategy => {
3306 self.pivot_melt_modal.melt_move_strategy(true);
3307 }
3308 PivotMeltFocus::MeltType => {
3309 self.pivot_melt_modal.melt_move_type_filter(true);
3310 }
3311 PivotMeltFocus::MeltExplicitList => {
3312 self.pivot_melt_modal.melt_move_explicit_selection(true);
3313 }
3314 _ => {}
3315 }
3316 }
3317 KeyCode::Char(' ') if !pivot_melt_text_focus => match self.pivot_melt_modal.focus {
3318 PivotMeltFocus::PivotIndexList => {
3319 self.pivot_melt_modal.pivot_toggle_index_at_selection();
3320 }
3321 PivotMeltFocus::MeltIndexList => {
3322 self.pivot_melt_modal.melt_toggle_index_at_selection();
3323 }
3324 PivotMeltFocus::MeltExplicitList => {
3325 self.pivot_melt_modal.melt_toggle_explicit_at_selection();
3326 }
3327 _ => {}
3328 },
3329 KeyCode::Home
3330 | KeyCode::End
3331 | KeyCode::Char(_)
3332 | KeyCode::Backspace
3333 | KeyCode::Delete
3334 if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter =>
3335 {
3336 self.pivot_melt_modal
3337 .pivot_filter_input
3338 .handle_key(event, None);
3339 self.pivot_melt_modal.pivot_index_table.select(None);
3340 }
3341 KeyCode::Home
3342 | KeyCode::End
3343 | KeyCode::Char(_)
3344 | KeyCode::Backspace
3345 | KeyCode::Delete
3346 if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter =>
3347 {
3348 self.pivot_melt_modal
3349 .melt_filter_input
3350 .handle_key(event, None);
3351 self.pivot_melt_modal.melt_index_table.select(None);
3352 }
3353 KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3354 self.pivot_melt_modal.melt_pattern_cursor = 0;
3355 }
3356 KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3357 self.pivot_melt_modal.melt_pattern_cursor =
3358 self.pivot_melt_modal.melt_pattern.chars().count();
3359 }
3360 KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3361 let byte_pos: usize = self
3362 .pivot_melt_modal
3363 .melt_pattern
3364 .chars()
3365 .take(self.pivot_melt_modal.melt_pattern_cursor)
3366 .map(|ch| ch.len_utf8())
3367 .sum();
3368 self.pivot_melt_modal.melt_pattern.insert(byte_pos, c);
3369 self.pivot_melt_modal.melt_pattern_cursor += 1;
3370 }
3371 KeyCode::Backspace
3372 if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern =>
3373 {
3374 if self.pivot_melt_modal.melt_pattern_cursor > 0 {
3375 let prev_byte: usize = self
3376 .pivot_melt_modal
3377 .melt_pattern
3378 .chars()
3379 .take(self.pivot_melt_modal.melt_pattern_cursor - 1)
3380 .map(|ch| ch.len_utf8())
3381 .sum();
3382 if let Some(ch) = self.pivot_melt_modal.melt_pattern[prev_byte..]
3383 .chars()
3384 .next()
3385 {
3386 self.pivot_melt_modal
3387 .melt_pattern
3388 .drain(prev_byte..prev_byte + ch.len_utf8());
3389 self.pivot_melt_modal.melt_pattern_cursor -= 1;
3390 }
3391 }
3392 }
3393 KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3394 let n = self.pivot_melt_modal.melt_pattern.chars().count();
3395 if self.pivot_melt_modal.melt_pattern_cursor < n {
3396 let byte_pos: usize = self
3397 .pivot_melt_modal
3398 .melt_pattern
3399 .chars()
3400 .take(self.pivot_melt_modal.melt_pattern_cursor)
3401 .map(|ch| ch.len_utf8())
3402 .sum();
3403 if let Some(ch) = self.pivot_melt_modal.melt_pattern[byte_pos..]
3404 .chars()
3405 .next()
3406 {
3407 self.pivot_melt_modal
3408 .melt_pattern
3409 .drain(byte_pos..byte_pos + ch.len_utf8());
3410 }
3411 }
3412 }
3413 KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3414 self.pivot_melt_modal.melt_variable_cursor = 0;
3415 }
3416 KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3417 self.pivot_melt_modal.melt_variable_cursor =
3418 self.pivot_melt_modal.melt_variable_name.chars().count();
3419 }
3420 KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3421 let byte_pos: usize = self
3422 .pivot_melt_modal
3423 .melt_variable_name
3424 .chars()
3425 .take(self.pivot_melt_modal.melt_variable_cursor)
3426 .map(|ch| ch.len_utf8())
3427 .sum();
3428 self.pivot_melt_modal.melt_variable_name.insert(byte_pos, c);
3429 self.pivot_melt_modal.melt_variable_cursor += 1;
3430 }
3431 KeyCode::Backspace
3432 if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName =>
3433 {
3434 if self.pivot_melt_modal.melt_variable_cursor > 0 {
3435 let prev_byte: usize = self
3436 .pivot_melt_modal
3437 .melt_variable_name
3438 .chars()
3439 .take(self.pivot_melt_modal.melt_variable_cursor - 1)
3440 .map(|ch| ch.len_utf8())
3441 .sum();
3442 if let Some(ch) = self.pivot_melt_modal.melt_variable_name[prev_byte..]
3443 .chars()
3444 .next()
3445 {
3446 self.pivot_melt_modal
3447 .melt_variable_name
3448 .drain(prev_byte..prev_byte + ch.len_utf8());
3449 self.pivot_melt_modal.melt_variable_cursor -= 1;
3450 }
3451 }
3452 }
3453 KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3454 let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3455 if self.pivot_melt_modal.melt_variable_cursor < n {
3456 let byte_pos: usize = self
3457 .pivot_melt_modal
3458 .melt_variable_name
3459 .chars()
3460 .take(self.pivot_melt_modal.melt_variable_cursor)
3461 .map(|ch| ch.len_utf8())
3462 .sum();
3463 if let Some(ch) = self.pivot_melt_modal.melt_variable_name[byte_pos..]
3464 .chars()
3465 .next()
3466 {
3467 self.pivot_melt_modal
3468 .melt_variable_name
3469 .drain(byte_pos..byte_pos + ch.len_utf8());
3470 }
3471 }
3472 }
3473 KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3474 self.pivot_melt_modal.melt_value_cursor = 0;
3475 }
3476 KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3477 self.pivot_melt_modal.melt_value_cursor =
3478 self.pivot_melt_modal.melt_value_name.chars().count();
3479 }
3480 KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3481 let byte_pos: usize = self
3482 .pivot_melt_modal
3483 .melt_value_name
3484 .chars()
3485 .take(self.pivot_melt_modal.melt_value_cursor)
3486 .map(|ch| ch.len_utf8())
3487 .sum();
3488 self.pivot_melt_modal.melt_value_name.insert(byte_pos, c);
3489 self.pivot_melt_modal.melt_value_cursor += 1;
3490 }
3491 KeyCode::Backspace
3492 if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName =>
3493 {
3494 if self.pivot_melt_modal.melt_value_cursor > 0 {
3495 let prev_byte: usize = self
3496 .pivot_melt_modal
3497 .melt_value_name
3498 .chars()
3499 .take(self.pivot_melt_modal.melt_value_cursor - 1)
3500 .map(|ch| ch.len_utf8())
3501 .sum();
3502 if let Some(ch) = self.pivot_melt_modal.melt_value_name[prev_byte..]
3503 .chars()
3504 .next()
3505 {
3506 self.pivot_melt_modal
3507 .melt_value_name
3508 .drain(prev_byte..prev_byte + ch.len_utf8());
3509 self.pivot_melt_modal.melt_value_cursor -= 1;
3510 }
3511 }
3512 }
3513 KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3514 let n = self.pivot_melt_modal.melt_value_name.chars().count();
3515 if self.pivot_melt_modal.melt_value_cursor < n {
3516 let byte_pos: usize = self
3517 .pivot_melt_modal
3518 .melt_value_name
3519 .chars()
3520 .take(self.pivot_melt_modal.melt_value_cursor)
3521 .map(|ch| ch.len_utf8())
3522 .sum();
3523 if let Some(ch) = self.pivot_melt_modal.melt_value_name[byte_pos..]
3524 .chars()
3525 .next()
3526 {
3527 self.pivot_melt_modal
3528 .melt_value_name
3529 .drain(byte_pos..byte_pos + ch.len_utf8());
3530 }
3531 }
3532 }
3533 _ => {}
3534 }
3535 return None;
3536 }
3537
3538 if self.input_mode == InputMode::Info {
3539 let on_tab_bar = self.info_modal.focus == InfoFocus::TabBar;
3540 let on_body = self.info_modal.focus == InfoFocus::Body;
3541 let schema_tab = self.info_modal.active_tab == InfoTab::Schema;
3542 let total_rows = self
3543 .data_table_state
3544 .as_ref()
3545 .map(|s| s.schema.len())
3546 .unwrap_or(0);
3547 let visible = self.info_modal.schema_visible_height;
3548
3549 match event.code {
3550 KeyCode::Esc | KeyCode::Char('i') if event.is_press() => {
3551 self.info_modal.close();
3552 self.input_mode = InputMode::Normal;
3553 }
3554 KeyCode::Tab if event.is_press() => {
3555 if schema_tab {
3556 self.info_modal.next_focus();
3557 }
3558 }
3559 KeyCode::BackTab if event.is_press() => {
3560 if schema_tab {
3561 self.info_modal.prev_focus();
3562 }
3563 }
3564 KeyCode::Left | KeyCode::Char('h') if event.is_press() && on_tab_bar => {
3565 let has_partitions = self
3566 .data_table_state
3567 .as_ref()
3568 .and_then(|s| s.partition_columns.as_ref())
3569 .map(|v| !v.is_empty())
3570 .unwrap_or(false);
3571 self.info_modal.switch_tab_prev(has_partitions);
3572 }
3573 KeyCode::Right | KeyCode::Char('l') if event.is_press() && on_tab_bar => {
3574 let has_partitions = self
3575 .data_table_state
3576 .as_ref()
3577 .and_then(|s| s.partition_columns.as_ref())
3578 .map(|v| !v.is_empty())
3579 .unwrap_or(false);
3580 self.info_modal.switch_tab(has_partitions);
3581 }
3582 KeyCode::Down | KeyCode::Char('j') if event.is_press() && on_body && schema_tab => {
3583 self.info_modal.schema_table_down(total_rows, visible);
3584 }
3585 KeyCode::Up | KeyCode::Char('k') if event.is_press() && on_body && schema_tab => {
3586 self.info_modal.schema_table_up(total_rows, visible);
3587 }
3588 _ => {}
3589 }
3590 return None;
3591 }
3592
3593 if self.input_mode == InputMode::Chart {
3594 if self.chart_export_modal.active {
3596 match event.code {
3597 KeyCode::Esc if event.is_press() => {
3598 self.chart_export_modal.close();
3599 }
3600 KeyCode::Tab if event.is_press() => {
3601 self.chart_export_modal.next_focus();
3602 }
3603 KeyCode::BackTab if event.is_press() => {
3604 self.chart_export_modal.prev_focus();
3605 }
3606 KeyCode::Left | KeyCode::Char('h')
3608 if event.is_press()
3609 && self.chart_export_modal.focus
3610 == ChartExportFocus::FormatSelector =>
3611 {
3612 let idx = ChartExportFormat::ALL
3613 .iter()
3614 .position(|&f| f == self.chart_export_modal.selected_format)
3615 .unwrap_or(0);
3616 let prev = if idx == 0 {
3617 ChartExportFormat::ALL.len() - 1
3618 } else {
3619 idx - 1
3620 };
3621 self.chart_export_modal.selected_format = ChartExportFormat::ALL[prev];
3622 }
3623 KeyCode::Right | KeyCode::Char('l')
3624 if event.is_press()
3625 && self.chart_export_modal.focus
3626 == ChartExportFocus::FormatSelector =>
3627 {
3628 let idx = ChartExportFormat::ALL
3629 .iter()
3630 .position(|&f| f == self.chart_export_modal.selected_format)
3631 .unwrap_or(0);
3632 let next = (idx + 1) % ChartExportFormat::ALL.len();
3633 self.chart_export_modal.selected_format = ChartExportFormat::ALL[next];
3634 }
3635 KeyCode::Enter if event.is_press() => match self.chart_export_modal.focus {
3636 ChartExportFocus::PathInput | ChartExportFocus::ExportButton => {
3637 let path_str = self.chart_export_modal.path_input.value.trim();
3638 if !path_str.is_empty() {
3639 let title =
3640 self.chart_export_modal.title_input.value.trim().to_string();
3641 let mut path = PathBuf::from(path_str);
3642 let format = self.chart_export_modal.selected_format;
3643 if path.extension().is_none() {
3645 path.set_extension(format.extension());
3646 }
3647 let path_display = path.display().to_string();
3648 if path.exists() {
3649 self.pending_chart_export = Some((path, format, title));
3650 self.chart_export_modal.close();
3651 self.confirmation_modal.show(format!(
3652 "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3653 path_display
3654 ));
3655 } else {
3656 self.chart_export_modal.close();
3657 return Some(AppEvent::ChartExport(path, format, title));
3658 }
3659 }
3660 }
3661 ChartExportFocus::CancelButton => {
3662 self.chart_export_modal.close();
3663 }
3664 _ => {}
3665 },
3666 _ => {
3667 if event.is_press() {
3668 if self.chart_export_modal.focus == ChartExportFocus::TitleInput {
3669 let _ = self.chart_export_modal.title_input.handle_key(event, None);
3670 } else if self.chart_export_modal.focus == ChartExportFocus::PathInput {
3671 let _ = self.chart_export_modal.path_input.handle_key(event, None);
3672 }
3673 }
3674 }
3675 }
3676 return None;
3677 }
3678
3679 match event.code {
3680 KeyCode::Char('e')
3681 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3682 {
3683 if self.data_table_state.is_some() && self.chart_modal.can_export() {
3685 self.chart_export_modal
3686 .open(&self.theme, self.history_limit);
3687 }
3688 }
3689 KeyCode::Char('?') if event.is_press() => {
3691 self.show_help = true;
3692 }
3693 KeyCode::Esc if event.is_press() => {
3694 self.chart_modal.close();
3695 self.chart_cache.clear();
3696 self.input_mode = InputMode::Normal;
3697 }
3698 KeyCode::Tab if event.is_press() => {
3699 self.chart_modal.next_focus();
3700 }
3701 KeyCode::BackTab if event.is_press() => {
3702 self.chart_modal.prev_focus();
3703 }
3704 KeyCode::Enter | KeyCode::Char(' ') if event.is_press() => {
3705 match self.chart_modal.focus {
3706 ChartFocus::YStartsAtZero => self.chart_modal.toggle_y_starts_at_zero(),
3707 ChartFocus::LogScale => self.chart_modal.toggle_log_scale(),
3708 ChartFocus::ShowLegend => self.chart_modal.toggle_show_legend(),
3709 ChartFocus::XList => self.chart_modal.x_list_toggle(),
3710 ChartFocus::YList => self.chart_modal.y_list_toggle(),
3711 ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3712 ChartFocus::HistList => self.chart_modal.hist_list_toggle(),
3713 ChartFocus::BoxList => self.chart_modal.box_list_toggle(),
3714 ChartFocus::KdeList => self.chart_modal.kde_list_toggle(),
3715 ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_toggle(),
3716 ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_toggle(),
3717 _ => {}
3718 }
3719 }
3720 KeyCode::Char('+') | KeyCode::Char('=')
3721 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3722 {
3723 match self.chart_modal.focus {
3724 ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3725 ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3726 ChartFocus::KdeBandwidth => self
3727 .chart_modal
3728 .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3729 ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3730 _ => {}
3731 }
3732 }
3733 KeyCode::Char('-')
3734 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3735 {
3736 match self.chart_modal.focus {
3737 ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3738 ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3739 ChartFocus::KdeBandwidth => self
3740 .chart_modal
3741 .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3742 ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3743 _ => {}
3744 }
3745 }
3746 KeyCode::Left | KeyCode::Char('h')
3747 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3748 {
3749 match self.chart_modal.focus {
3750 ChartFocus::TabBar => self.chart_modal.prev_chart_kind(),
3751 ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3752 ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3753 ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3754 ChartFocus::KdeBandwidth => self
3755 .chart_modal
3756 .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3757 ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3758 _ => {}
3759 }
3760 }
3761 KeyCode::Right | KeyCode::Char('l')
3762 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3763 {
3764 match self.chart_modal.focus {
3765 ChartFocus::TabBar => self.chart_modal.next_chart_kind(),
3766 ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3767 ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3768 ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3769 ChartFocus::KdeBandwidth => self
3770 .chart_modal
3771 .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3772 ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3773 _ => {}
3774 }
3775 }
3776 KeyCode::PageUp
3777 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3778 {
3779 if self.chart_modal.focus == ChartFocus::LimitRows {
3780 self.chart_modal.adjust_row_limit_page(1);
3781 }
3782 }
3783 KeyCode::PageDown
3784 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3785 {
3786 if self.chart_modal.focus == ChartFocus::LimitRows {
3787 self.chart_modal.adjust_row_limit_page(-1);
3788 }
3789 }
3790 KeyCode::Up | KeyCode::Char('k')
3791 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3792 {
3793 match self.chart_modal.focus {
3794 ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3795 ChartFocus::XList => self.chart_modal.x_list_up(),
3796 ChartFocus::YList => self.chart_modal.y_list_up(),
3797 ChartFocus::HistList => self.chart_modal.hist_list_up(),
3798 ChartFocus::BoxList => self.chart_modal.box_list_up(),
3799 ChartFocus::KdeList => self.chart_modal.kde_list_up(),
3800 ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_up(),
3801 ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_up(),
3802 _ => {}
3803 }
3804 }
3805 KeyCode::Down | KeyCode::Char('j')
3806 if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3807 {
3808 match self.chart_modal.focus {
3809 ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3810 ChartFocus::XList => self.chart_modal.x_list_down(),
3811 ChartFocus::YList => self.chart_modal.y_list_down(),
3812 ChartFocus::HistList => self.chart_modal.hist_list_down(),
3813 ChartFocus::BoxList => self.chart_modal.box_list_down(),
3814 ChartFocus::KdeList => self.chart_modal.kde_list_down(),
3815 ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_down(),
3816 ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_down(),
3817 _ => {}
3818 }
3819 }
3820 _ => {
3821 if event.is_press() {
3823 if self.chart_modal.focus == ChartFocus::XInput {
3824 let _ = self.chart_modal.x_input.handle_key(event, None);
3825 } else if self.chart_modal.focus == ChartFocus::YInput {
3826 let _ = self.chart_modal.y_input.handle_key(event, None);
3827 } else if self.chart_modal.focus == ChartFocus::HistInput {
3828 let _ = self.chart_modal.hist_input.handle_key(event, None);
3829 } else if self.chart_modal.focus == ChartFocus::BoxInput {
3830 let _ = self.chart_modal.box_input.handle_key(event, None);
3831 } else if self.chart_modal.focus == ChartFocus::KdeInput {
3832 let _ = self.chart_modal.kde_input.handle_key(event, None);
3833 } else if self.chart_modal.focus == ChartFocus::HeatmapXInput {
3834 let _ = self.chart_modal.heatmap_x_input.handle_key(event, None);
3835 } else if self.chart_modal.focus == ChartFocus::HeatmapYInput {
3836 let _ = self.chart_modal.heatmap_y_input.handle_key(event, None);
3837 }
3838 }
3839 }
3840 }
3841 return None;
3842 }
3843
3844 if self.analysis_modal.active {
3845 match event.code {
3846 KeyCode::Esc => {
3847 if self.analysis_modal.show_help {
3848 self.analysis_modal.show_help = false;
3849 } else if self.analysis_modal.view != analysis_modal::AnalysisView::Main {
3850 self.analysis_modal.close_detail();
3852 } else {
3853 self.analysis_modal.close();
3854 }
3855 }
3856 KeyCode::Char('?') => {
3857 self.analysis_modal.show_help = !self.analysis_modal.show_help;
3858 }
3859 KeyCode::Char('r') => {
3860 if self.sampling_threshold.is_some() {
3861 self.analysis_modal.recalculate();
3862 match self.analysis_modal.selected_tool {
3863 Some(analysis_modal::AnalysisTool::Describe) => {
3864 self.analysis_modal.describe_results = None;
3865 self.analysis_modal.computing = Some(AnalysisProgress {
3866 phase: "Describing data".to_string(),
3867 current: 0,
3868 total: 1,
3869 });
3870 self.analysis_computation = Some(AnalysisComputationState {
3871 df: None,
3872 schema: None,
3873 partial_stats: Vec::new(),
3874 current: 0,
3875 total: 0,
3876 total_rows: 0,
3877 sample_seed: self.analysis_modal.random_seed,
3878 sample_size: None,
3879 });
3880 self.busy = true;
3881 return Some(AppEvent::AnalysisChunk);
3882 }
3883 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
3884 self.analysis_modal.distribution_results = None;
3885 self.analysis_modal.computing = Some(AnalysisProgress {
3886 phase: "Distribution".to_string(),
3887 current: 0,
3888 total: 1,
3889 });
3890 self.busy = true;
3891 return Some(AppEvent::AnalysisDistributionCompute);
3892 }
3893 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3894 self.analysis_modal.correlation_results = None;
3895 self.analysis_modal.computing = Some(AnalysisProgress {
3896 phase: "Correlation".to_string(),
3897 current: 0,
3898 total: 1,
3899 });
3900 self.busy = true;
3901 return Some(AppEvent::AnalysisCorrelationCompute);
3902 }
3903 None => {}
3904 }
3905 }
3906 }
3907 KeyCode::Tab => {
3908 if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
3909 self.analysis_modal.switch_focus();
3911 } else if self.analysis_modal.view
3912 == analysis_modal::AnalysisView::DistributionDetail
3913 {
3914 } else {
3917 self.analysis_modal.next_detail_section();
3919 }
3920 }
3921 KeyCode::Enter => {
3922 if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
3923 if self.analysis_modal.focus == analysis_modal::AnalysisFocus::Sidebar {
3924 self.analysis_modal.select_tool();
3926 match self.analysis_modal.selected_tool {
3928 Some(analysis_modal::AnalysisTool::Describe)
3929 if self.analysis_modal.describe_results.is_none() =>
3930 {
3931 self.analysis_modal.computing = Some(AnalysisProgress {
3932 phase: "Describing data".to_string(),
3933 current: 0,
3934 total: 1,
3935 });
3936 self.analysis_computation = Some(AnalysisComputationState {
3937 df: None,
3938 schema: None,
3939 partial_stats: Vec::new(),
3940 current: 0,
3941 total: 0,
3942 total_rows: 0,
3943 sample_seed: self.analysis_modal.random_seed,
3944 sample_size: None,
3945 });
3946 self.busy = true;
3947 return Some(AppEvent::AnalysisChunk);
3948 }
3949 Some(analysis_modal::AnalysisTool::DistributionAnalysis)
3950 if self.analysis_modal.distribution_results.is_none() =>
3951 {
3952 self.analysis_modal.computing = Some(AnalysisProgress {
3953 phase: "Distribution".to_string(),
3954 current: 0,
3955 total: 1,
3956 });
3957 self.busy = true;
3958 return Some(AppEvent::AnalysisDistributionCompute);
3959 }
3960 Some(analysis_modal::AnalysisTool::CorrelationMatrix)
3961 if self.analysis_modal.correlation_results.is_none() =>
3962 {
3963 self.analysis_modal.computing = Some(AnalysisProgress {
3964 phase: "Correlation".to_string(),
3965 current: 0,
3966 total: 1,
3967 });
3968 self.busy = true;
3969 return Some(AppEvent::AnalysisCorrelationCompute);
3970 }
3971 _ => {}
3972 }
3973 } else {
3974 match self.analysis_modal.selected_tool {
3976 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
3977 self.analysis_modal.open_distribution_detail();
3978 }
3979 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3980 self.analysis_modal.open_correlation_detail();
3981 }
3982 _ => {}
3983 }
3984 }
3985 }
3986 }
3987 KeyCode::Down | KeyCode::Char('j') => {
3988 match self.analysis_modal.view {
3989 analysis_modal::AnalysisView::Main => {
3990 match self.analysis_modal.focus {
3991 analysis_modal::AnalysisFocus::Sidebar => {
3992 self.analysis_modal.next_tool();
3994 }
3995 analysis_modal::AnalysisFocus::Main => {
3996 match self.analysis_modal.selected_tool {
3998 Some(analysis_modal::AnalysisTool::Describe) => {
3999 if let Some(state) = &self.data_table_state {
4000 let max_rows = state.schema.len();
4001 self.analysis_modal.next_row(max_rows);
4002 }
4003 }
4004 Some(
4005 analysis_modal::AnalysisTool::DistributionAnalysis,
4006 ) => {
4007 if let Some(results) =
4008 self.analysis_modal.current_results()
4009 {
4010 let max_rows = results.distribution_analyses.len();
4011 self.analysis_modal.next_row(max_rows);
4012 }
4013 }
4014 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4015 if let Some(results) =
4016 self.analysis_modal.current_results()
4017 {
4018 if let Some(corr) = &results.correlation_matrix {
4019 let max_rows = corr.columns.len();
4020 let row_header_width = 20u16;
4022 let cell_width = 12u16;
4023 let column_spacing = 1u16;
4024 let estimated_width = 80u16;
4025 let available_width = estimated_width
4026 .saturating_sub(row_header_width);
4027 let mut calculated_visible = 0usize;
4028 let mut used = 0u16;
4029 let max_cols = corr.columns.len();
4030 loop {
4031 let needed = if calculated_visible == 0 {
4032 cell_width
4033 } else {
4034 column_spacing + cell_width
4035 };
4036 if used + needed <= available_width
4037 && calculated_visible < max_cols
4038 {
4039 used += needed;
4040 calculated_visible += 1;
4041 } else {
4042 break;
4043 }
4044 }
4045 let visible_cols =
4046 calculated_visible.max(1).min(max_cols);
4047 self.analysis_modal.move_correlation_cell(
4048 (1, 0),
4049 max_rows,
4050 max_rows,
4051 visible_cols,
4052 );
4053 }
4054 }
4055 }
4056 None => {}
4057 }
4058 }
4059 _ => {}
4060 }
4061 }
4062 analysis_modal::AnalysisView::DistributionDetail => {
4063 if self.analysis_modal.focus
4064 == analysis_modal::AnalysisFocus::DistributionSelector
4065 {
4066 self.analysis_modal.next_distribution();
4067 }
4068 }
4069 _ => {}
4070 }
4071 }
4072 KeyCode::Char('s') => {
4073 if self.analysis_modal.view == analysis_modal::AnalysisView::DistributionDetail
4075 {
4076 self.analysis_modal.histogram_scale =
4077 match self.analysis_modal.histogram_scale {
4078 analysis_modal::HistogramScale::Linear => {
4079 analysis_modal::HistogramScale::Log
4080 }
4081 analysis_modal::HistogramScale::Log => {
4082 analysis_modal::HistogramScale::Linear
4083 }
4084 };
4085 }
4086 }
4087 KeyCode::Up | KeyCode::Char('k') => {
4088 if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4089 self.analysis_modal.previous_row();
4090 } else if self.analysis_modal.view
4091 == analysis_modal::AnalysisView::DistributionDetail
4092 && self.analysis_modal.focus
4093 == analysis_modal::AnalysisFocus::DistributionSelector
4094 {
4095 self.analysis_modal.previous_distribution();
4096 }
4097 }
4098 KeyCode::Left | KeyCode::Char('h')
4099 if !event.modifiers.contains(KeyModifiers::CONTROL) =>
4100 {
4101 if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4102 match self.analysis_modal.focus {
4103 analysis_modal::AnalysisFocus::Sidebar => {
4104 }
4106 analysis_modal::AnalysisFocus::DistributionSelector => {
4107 }
4109 analysis_modal::AnalysisFocus::Main => {
4110 match self.analysis_modal.selected_tool {
4111 Some(analysis_modal::AnalysisTool::Describe) => {
4112 self.analysis_modal.scroll_left();
4113 }
4114 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4115 self.analysis_modal.scroll_left();
4116 }
4117 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4118 if let Some(results) = self.analysis_modal.current_results()
4119 {
4120 if let Some(corr) = &results.correlation_matrix {
4121 let max_cols = corr.columns.len();
4122 let row_header_width = 20u16;
4125 let cell_width = 12u16;
4126 let column_spacing = 1u16;
4127 let estimated_width = 80u16; let available_width = estimated_width
4132 .saturating_sub(row_header_width);
4133 let mut calculated_visible = 0usize;
4135 let mut used = 0u16;
4136 loop {
4137 let needed = if calculated_visible == 0 {
4138 cell_width
4139 } else {
4140 column_spacing + cell_width
4141 };
4142 if used + needed <= available_width
4143 && calculated_visible < max_cols
4144 {
4145 used += needed;
4146 calculated_visible += 1;
4147 } else {
4148 break;
4149 }
4150 }
4151 let visible_cols =
4152 calculated_visible.max(1).min(max_cols);
4153 self.analysis_modal.move_correlation_cell(
4154 (0, -1),
4155 max_cols,
4156 max_cols,
4157 visible_cols,
4158 );
4159 }
4160 }
4161 }
4162 None => {}
4163 }
4164 }
4165 }
4166 }
4167 }
4168 KeyCode::Right | KeyCode::Char('l') => {
4169 if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4170 match self.analysis_modal.focus {
4171 analysis_modal::AnalysisFocus::Sidebar => {
4172 }
4174 analysis_modal::AnalysisFocus::DistributionSelector => {
4175 }
4177 analysis_modal::AnalysisFocus::Main => {
4178 match self.analysis_modal.selected_tool {
4179 Some(analysis_modal::AnalysisTool::Describe) => {
4180 let max_stats = 12;
4182 let visible_stats = 8; self.analysis_modal.scroll_right(max_stats, visible_stats);
4185 }
4186 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4187 let max_stats = 8;
4189 let visible_stats = 6; self.analysis_modal.scroll_right(max_stats, visible_stats);
4192 }
4193 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4194 if let Some(results) = self.analysis_modal.current_results()
4195 {
4196 if let Some(corr) = &results.correlation_matrix {
4197 let max_cols = corr.columns.len();
4198 let row_header_width = 20u16;
4200 let cell_width = 12u16;
4201 let column_spacing = 1u16;
4202 let estimated_width = 80u16; let available_width = estimated_width
4204 .saturating_sub(row_header_width);
4205 let mut calculated_visible = 0usize;
4206 let mut used = 0u16;
4207 loop {
4208 let needed = if calculated_visible == 0 {
4209 cell_width
4210 } else {
4211 column_spacing + cell_width
4212 };
4213 if used + needed <= available_width
4214 && calculated_visible < max_cols
4215 {
4216 used += needed;
4217 calculated_visible += 1;
4218 } else {
4219 break;
4220 }
4221 }
4222 let visible_cols =
4223 calculated_visible.max(1).min(max_cols);
4224 self.analysis_modal.move_correlation_cell(
4225 (0, 1),
4226 max_cols,
4227 max_cols,
4228 visible_cols,
4229 );
4230 }
4231 }
4232 }
4233 None => {}
4234 }
4235 }
4236 }
4237 }
4238 }
4239 KeyCode::PageDown => {
4240 if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4241 && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4242 {
4243 match self.analysis_modal.selected_tool {
4244 Some(analysis_modal::AnalysisTool::Describe) => {
4245 if let Some(state) = &self.data_table_state {
4246 let max_rows = state.schema.len();
4247 let page_size = 10;
4248 self.analysis_modal.page_down(max_rows, page_size);
4249 }
4250 }
4251 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4252 if let Some(results) = self.analysis_modal.current_results() {
4253 let max_rows = results.distribution_analyses.len();
4254 let page_size = 10;
4255 self.analysis_modal.page_down(max_rows, page_size);
4256 }
4257 }
4258 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4259 if let Some(results) = self.analysis_modal.current_results() {
4260 if let Some(corr) = &results.correlation_matrix {
4261 let max_rows = corr.columns.len();
4262 let page_size = 10;
4263 self.analysis_modal.page_down(max_rows, page_size);
4264 }
4265 }
4266 }
4267 None => {}
4268 }
4269 }
4270 }
4271 KeyCode::PageUp => {
4272 if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4273 && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4274 {
4275 let page_size = 10;
4276 self.analysis_modal.page_up(page_size);
4277 }
4278 }
4279 KeyCode::Home => {
4280 if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4281 match self.analysis_modal.focus {
4282 analysis_modal::AnalysisFocus::Sidebar => {
4283 self.analysis_modal.sidebar_state.select(Some(0));
4284 }
4285 analysis_modal::AnalysisFocus::DistributionSelector => {
4286 self.analysis_modal
4287 .distribution_selector_state
4288 .select(Some(0));
4289 }
4290 analysis_modal::AnalysisFocus::Main => {
4291 match self.analysis_modal.selected_tool {
4292 Some(analysis_modal::AnalysisTool::Describe) => {
4293 self.analysis_modal.table_state.select(Some(0));
4294 }
4295 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4296 self.analysis_modal
4297 .distribution_table_state
4298 .select(Some(0));
4299 }
4300 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4301 self.analysis_modal.correlation_table_state.select(Some(0));
4302 self.analysis_modal.selected_correlation = Some((0, 0));
4303 }
4304 None => {}
4305 }
4306 }
4307 }
4308 }
4309 }
4310 KeyCode::End => {
4311 if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4312 match self.analysis_modal.focus {
4313 analysis_modal::AnalysisFocus::Sidebar => {
4314 self.analysis_modal.sidebar_state.select(Some(2));
4315 }
4317 analysis_modal::AnalysisFocus::DistributionSelector => {
4318 self.analysis_modal
4319 .distribution_selector_state
4320 .select(Some(13)); }
4322 analysis_modal::AnalysisFocus::Main => {
4323 match self.analysis_modal.selected_tool {
4324 Some(analysis_modal::AnalysisTool::Describe) => {
4325 if let Some(state) = &self.data_table_state {
4326 let max_rows = state.schema.len();
4327 if max_rows > 0 {
4328 self.analysis_modal
4329 .table_state
4330 .select(Some(max_rows - 1));
4331 }
4332 }
4333 }
4334 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4335 if let Some(results) = self.analysis_modal.current_results()
4336 {
4337 let max_rows = results.distribution_analyses.len();
4338 if max_rows > 0 {
4339 self.analysis_modal
4340 .distribution_table_state
4341 .select(Some(max_rows - 1));
4342 }
4343 }
4344 }
4345 Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4346 if let Some(results) = self.analysis_modal.current_results()
4347 {
4348 if let Some(corr) = &results.correlation_matrix {
4349 let max_rows = corr.columns.len();
4350 if max_rows > 0 {
4351 self.analysis_modal
4352 .correlation_table_state
4353 .select(Some(max_rows - 1));
4354 self.analysis_modal.selected_correlation =
4355 Some((max_rows - 1, max_rows - 1));
4356 }
4357 }
4358 }
4359 }
4360 None => {}
4361 }
4362 }
4363 }
4364 }
4365 }
4366 _ => {}
4367 }
4368 return None;
4369 }
4370
4371 if self.template_modal.active {
4372 match event.code {
4373 KeyCode::Esc => {
4374 if self.template_modal.show_score_details {
4375 self.template_modal.show_score_details = false;
4377 } else if self.template_modal.delete_confirm {
4378 self.template_modal.delete_confirm = false;
4380 } else if self.template_modal.mode == TemplateModalMode::Create
4381 || self.template_modal.mode == TemplateModalMode::Edit
4382 {
4383 self.template_modal.exit_create_mode();
4385 } else {
4386 if self.template_modal.show_help {
4388 self.template_modal.show_help = false;
4389 } else {
4390 self.template_modal.active = false;
4391 self.template_modal.show_help = false;
4392 self.template_modal.delete_confirm = false;
4393 }
4394 }
4395 }
4396 KeyCode::BackTab if self.template_modal.delete_confirm => {
4397 self.template_modal.delete_confirm_focus =
4398 !self.template_modal.delete_confirm_focus;
4399 }
4400 KeyCode::Left
4401 | KeyCode::Right
4402 | KeyCode::Up
4403 | KeyCode::Down
4404 | KeyCode::Char('h')
4405 | KeyCode::Char('l')
4406 | KeyCode::Char('j')
4407 | KeyCode::Char('k')
4408 if self.template_modal.delete_confirm =>
4409 {
4410 self.template_modal.delete_confirm_focus =
4411 !self.template_modal.delete_confirm_focus;
4412 }
4413 KeyCode::Tab if !self.template_modal.delete_confirm => {
4414 self.template_modal.next_focus();
4415 }
4416 KeyCode::BackTab => {
4417 self.template_modal.prev_focus();
4418 }
4419 KeyCode::Char('s') if self.template_modal.mode == TemplateModalMode::List => {
4420 self.template_modal
4422 .enter_create_mode(self.history_limit, &self.theme);
4423 if let Some(ref path) = self.path {
4425 self.template_modal.create_name_input.value =
4427 self.template_manager.generate_next_template_name();
4428 self.template_modal.create_name_input.cursor =
4429 self.template_modal.create_name_input.value.chars().count();
4430
4431 let absolute_path = if path.is_absolute() {
4433 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4434 } else {
4435 if let Ok(cwd) = std::env::current_dir() {
4437 let abs = cwd.join(path);
4438 abs.canonicalize().unwrap_or(abs)
4439 } else {
4440 path.to_path_buf()
4441 }
4442 };
4443 self.template_modal.create_exact_path_input.value =
4444 absolute_path.to_string_lossy().to_string();
4445 self.template_modal.create_exact_path_input.cursor = self
4446 .template_modal
4447 .create_exact_path_input
4448 .value
4449 .chars()
4450 .count();
4451
4452 if let Ok(cwd) = std::env::current_dir() {
4454 let abs_path = if path.is_absolute() {
4455 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4456 } else {
4457 let abs = cwd.join(path);
4458 abs.canonicalize().unwrap_or(abs)
4459 };
4460 if let Ok(canonical_cwd) = cwd.canonicalize() {
4461 if let Ok(rel_path) = abs_path.strip_prefix(&canonical_cwd) {
4462 let rel_str = rel_path.to_string_lossy().to_string();
4464 self.template_modal.create_relative_path_input.value =
4465 rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4466 self.template_modal.create_relative_path_input.cursor = self
4467 .template_modal
4468 .create_relative_path_input
4469 .value
4470 .chars()
4471 .count();
4472 } else {
4473 self.template_modal.create_relative_path_input.clear();
4475 }
4476 } else {
4477 if let Ok(rel_path) = abs_path.strip_prefix(&cwd) {
4479 let rel_str = rel_path.to_string_lossy().to_string();
4480 self.template_modal.create_relative_path_input.value =
4481 rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4482 self.template_modal.create_relative_path_input.cursor = self
4483 .template_modal
4484 .create_relative_path_input
4485 .value
4486 .chars()
4487 .count();
4488 } else {
4489 self.template_modal.create_relative_path_input.clear();
4490 }
4491 }
4492 } else {
4493 self.template_modal.create_relative_path_input.clear();
4494 }
4495
4496 if let Some(parent) = path.parent() {
4498 if let Some(parent_str) = parent.to_str() {
4499 if path.file_name().is_some() {
4500 if let Some(ext) = path.extension() {
4501 self.template_modal.create_path_pattern_input.value =
4502 format!("{}/*.{}", parent_str, ext.to_string_lossy());
4503 self.template_modal.create_path_pattern_input.cursor = self
4504 .template_modal
4505 .create_path_pattern_input
4506 .value
4507 .chars()
4508 .count();
4509 }
4510 }
4511 }
4512 }
4513
4514 if let Some(filename) = path.file_name() {
4516 if let Some(filename_str) = filename.to_str() {
4517 let mut pattern = filename_str.to_string();
4519 use regex::Regex;
4521 if let Ok(re) = Regex::new(r"\d+") {
4522 pattern = re.replace_all(&pattern, "*").to_string();
4523 }
4524 self.template_modal.create_filename_pattern_input.value = pattern;
4525 self.template_modal.create_filename_pattern_input.cursor = self
4526 .template_modal
4527 .create_filename_pattern_input
4528 .value
4529 .chars()
4530 .count();
4531 }
4532 }
4533 }
4534
4535 if let Some(ref state) = self.data_table_state {
4537 if !state.schema.is_empty() {
4538 self.template_modal.create_schema_match_enabled = false;
4539 }
4541 }
4542 }
4543 KeyCode::Char('e') if self.template_modal.mode == TemplateModalMode::List => {
4544 if let Some(idx) = self.template_modal.table_state.selected() {
4546 if let Some((template, _)) = self.template_modal.templates.get(idx) {
4547 let template_clone = template.clone();
4548 self.template_modal.enter_edit_mode(
4549 &template_clone,
4550 self.history_limit,
4551 &self.theme,
4552 );
4553 }
4554 }
4555 }
4556 KeyCode::Char('d')
4557 if self.template_modal.mode == TemplateModalMode::List
4558 && !self.template_modal.delete_confirm =>
4559 {
4560 if let Some(_idx) = self.template_modal.table_state.selected() {
4562 self.template_modal.delete_confirm = true;
4563 self.template_modal.delete_confirm_focus = false; }
4565 }
4566 KeyCode::Char('?')
4567 if self.template_modal.mode == TemplateModalMode::List
4568 && !self.template_modal.delete_confirm =>
4569 {
4570 self.template_modal.show_score_details = true;
4572 }
4573 KeyCode::Char('D') if self.template_modal.delete_confirm => {
4574 if let Some(idx) = self.template_modal.table_state.selected() {
4576 if let Some((template, _)) = self.template_modal.templates.get(idx) {
4577 if self.template_manager.delete_template(&template.id).is_err() {
4578 } else {
4580 if let Some(ref state) = self.data_table_state {
4582 if let Some(ref path) = self.path {
4583 self.template_modal.templates = self
4584 .template_manager
4585 .find_relevant_templates(path, &state.schema);
4586 if !self.template_modal.templates.is_empty() {
4587 let new_idx = idx.min(
4588 self.template_modal
4589 .templates
4590 .len()
4591 .saturating_sub(1),
4592 );
4593 self.template_modal.table_state.select(Some(new_idx));
4594 } else {
4595 self.template_modal.table_state.select(None);
4596 }
4597 }
4598 }
4599 }
4600 self.template_modal.delete_confirm = false;
4601 }
4602 }
4603 }
4604 KeyCode::Tab if self.template_modal.delete_confirm => {
4605 self.template_modal.delete_confirm_focus =
4607 !self.template_modal.delete_confirm_focus;
4608 }
4609 KeyCode::Enter if self.template_modal.delete_confirm => {
4610 if self.template_modal.delete_confirm_focus {
4612 if let Some(idx) = self.template_modal.table_state.selected() {
4614 if let Some((template, _)) = self.template_modal.templates.get(idx) {
4615 if self.template_manager.delete_template(&template.id).is_err() {
4616 } else {
4618 if let Some(ref state) = self.data_table_state {
4620 if let Some(ref path) = self.path {
4621 self.template_modal.templates = self
4622 .template_manager
4623 .find_relevant_templates(path, &state.schema);
4624 if !self.template_modal.templates.is_empty() {
4625 let new_idx = idx.min(
4626 self.template_modal
4627 .templates
4628 .len()
4629 .saturating_sub(1),
4630 );
4631 self.template_modal
4632 .table_state
4633 .select(Some(new_idx));
4634 } else {
4635 self.template_modal.table_state.select(None);
4636 }
4637 }
4638 }
4639 }
4640 self.template_modal.delete_confirm = false;
4641 }
4642 }
4643 } else {
4644 self.template_modal.delete_confirm = false;
4646 }
4647 }
4648 KeyCode::Enter => {
4649 match self.template_modal.mode {
4650 TemplateModalMode::List => {
4651 match self.template_modal.focus {
4652 TemplateFocus::TemplateList => {
4653 let template_idx = self.template_modal.table_state.selected();
4655 if let Some(idx) = template_idx {
4656 if let Some((template, _)) =
4657 self.template_modal.templates.get(idx)
4658 {
4659 let template_clone = template.clone();
4660 if let Err(e) = self.apply_template(&template_clone) {
4661 self.error_modal.show(format!(
4663 "Error applying template: {}",
4664 e
4665 ));
4666 } else {
4668 self.template_modal.active = false;
4670 }
4671 }
4672 }
4673 }
4674 TemplateFocus::CreateButton => {
4675 }
4678 _ => {}
4679 }
4680 }
4681 TemplateModalMode::Create | TemplateModalMode::Edit => {
4682 if self.template_modal.create_focus == CreateFocus::Description {
4684 let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
4685 self.template_modal
4686 .create_description_input
4687 .handle_key(&event, None);
4688 let area_height = 10; self.template_modal
4691 .create_description_input
4692 .ensure_cursor_visible(area_height, 80);
4693 return None;
4694 }
4695 match self.template_modal.create_focus {
4696 CreateFocus::SaveButton => {
4697 self.template_modal.name_error = None;
4699 if self
4700 .template_modal
4701 .create_name_input
4702 .value
4703 .trim()
4704 .is_empty()
4705 {
4706 self.template_modal.name_error =
4707 Some("(required)".to_string());
4708 self.template_modal.create_focus = CreateFocus::Name;
4709 return None;
4710 }
4711
4712 if self.template_modal.editing_template_id.is_none()
4714 && self.template_manager.template_exists(
4715 self.template_modal.create_name_input.value.trim(),
4716 )
4717 {
4718 self.template_modal.name_error =
4719 Some("(name already exists)".to_string());
4720 self.template_modal.create_focus = CreateFocus::Name;
4721 return None;
4722 }
4723
4724 let match_criteria = template::MatchCriteria {
4726 exact_path: if !self
4727 .template_modal
4728 .create_exact_path_input
4729 .value
4730 .trim()
4731 .is_empty()
4732 {
4733 Some(std::path::PathBuf::from(
4734 self.template_modal
4735 .create_exact_path_input
4736 .value
4737 .trim(),
4738 ))
4739 } else {
4740 None
4741 },
4742 relative_path: if !self
4743 .template_modal
4744 .create_relative_path_input
4745 .value
4746 .trim()
4747 .is_empty()
4748 {
4749 Some(
4750 self.template_modal
4751 .create_relative_path_input
4752 .value
4753 .trim()
4754 .to_string(),
4755 )
4756 } else {
4757 None
4758 },
4759 path_pattern: if !self
4760 .template_modal
4761 .create_path_pattern_input
4762 .value
4763 .is_empty()
4764 {
4765 Some(
4766 self.template_modal
4767 .create_path_pattern_input
4768 .value
4769 .clone(),
4770 )
4771 } else {
4772 None
4773 },
4774 filename_pattern: if !self
4775 .template_modal
4776 .create_filename_pattern_input
4777 .value
4778 .is_empty()
4779 {
4780 Some(
4781 self.template_modal
4782 .create_filename_pattern_input
4783 .value
4784 .clone(),
4785 )
4786 } else {
4787 None
4788 },
4789 schema_columns: if self
4790 .template_modal
4791 .create_schema_match_enabled
4792 {
4793 self.data_table_state.as_ref().map(|state| {
4794 state
4795 .schema
4796 .iter_names()
4797 .map(|s| s.to_string())
4798 .collect()
4799 })
4800 } else {
4801 None
4802 },
4803 schema_types: None, };
4805
4806 let description = if !self
4807 .template_modal
4808 .create_description_input
4809 .value
4810 .is_empty()
4811 {
4812 Some(
4813 self.template_modal
4814 .create_description_input
4815 .value
4816 .clone(),
4817 )
4818 } else {
4819 None
4820 };
4821
4822 if let Some(ref editing_id) =
4823 self.template_modal.editing_template_id
4824 {
4825 if let Some(mut template) = self
4827 .template_manager
4828 .get_template_by_id(editing_id)
4829 .cloned()
4830 {
4831 template.name = self
4832 .template_modal
4833 .create_name_input
4834 .value
4835 .trim()
4836 .to_string();
4837 template.description = description;
4838 template.match_criteria = match_criteria;
4839 if let Some(state) = &self.data_table_state {
4841 let (query, sql_query, fuzzy_query) =
4842 active_query_settings(
4843 state.get_active_query(),
4844 state.get_active_sql_query(),
4845 state.get_active_fuzzy_query(),
4846 );
4847 template.settings = template::TemplateSettings {
4848 query,
4849 sql_query,
4850 fuzzy_query,
4851 filters: state.get_filters().to_vec(),
4852 sort_columns: state.get_sort_columns().to_vec(),
4853 sort_ascending: state.get_sort_ascending(),
4854 column_order: state.get_column_order().to_vec(),
4855 locked_columns_count: state
4856 .locked_columns_count(),
4857 pivot: state.last_pivot_spec().cloned(),
4858 melt: state.last_melt_spec().cloned(),
4859 };
4860 }
4861
4862 match self.template_manager.update_template(&template) {
4863 Ok(_) => {
4864 if let Some(ref state) = self.data_table_state {
4866 if let Some(ref path) = self.path {
4867 self.template_modal.templates = self
4868 .template_manager
4869 .find_relevant_templates(
4870 path,
4871 &state.schema,
4872 );
4873 self.template_modal.table_state.select(
4874 if self
4875 .template_modal
4876 .templates
4877 .is_empty()
4878 {
4879 None
4880 } else {
4881 Some(0)
4882 },
4883 );
4884 }
4885 }
4886 self.template_modal.exit_create_mode();
4887 }
4888 Err(_) => {
4889 }
4891 }
4892 }
4893 } else {
4894 match self.create_template_from_current_state(
4896 self.template_modal
4897 .create_name_input
4898 .value
4899 .trim()
4900 .to_string(),
4901 description,
4902 match_criteria,
4903 ) {
4904 Ok(_) => {
4905 if let Some(ref state) = self.data_table_state {
4907 if let Some(ref path) = self.path {
4908 self.template_modal.templates = self
4909 .template_manager
4910 .find_relevant_templates(
4911 path,
4912 &state.schema,
4913 );
4914 self.template_modal.table_state.select(
4915 if self
4916 .template_modal
4917 .templates
4918 .is_empty()
4919 {
4920 None
4921 } else {
4922 Some(0)
4923 },
4924 );
4925 }
4926 }
4927 self.template_modal.exit_create_mode();
4928 }
4929 Err(_) => {
4930 }
4932 }
4933 }
4934 }
4935 CreateFocus::CancelButton => {
4936 self.template_modal.exit_create_mode();
4937 }
4938 _ => {
4939 self.template_modal.next_focus();
4941 }
4942 }
4943 }
4944 }
4945 }
4946 KeyCode::Up => {
4947 match self.template_modal.mode {
4948 TemplateModalMode::List => {
4949 if self.template_modal.focus == TemplateFocus::TemplateList {
4950 let i = match self.template_modal.table_state.selected() {
4951 Some(i) => {
4952 if i == 0 {
4953 self.template_modal.templates.len().saturating_sub(1)
4954 } else {
4955 i - 1
4956 }
4957 }
4958 None => 0,
4959 };
4960 self.template_modal.table_state.select(Some(i));
4961 }
4962 }
4963 TemplateModalMode::Create | TemplateModalMode::Edit => {
4964 if self.template_modal.create_focus == CreateFocus::Description {
4966 let event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
4967 self.template_modal
4968 .create_description_input
4969 .handle_key(&event, None);
4970 let area_height = 10; self.template_modal
4973 .create_description_input
4974 .ensure_cursor_visible(area_height, 80);
4975 } else {
4976 self.template_modal.prev_focus();
4978 }
4979 }
4980 }
4981 }
4982 KeyCode::Down => {
4983 match self.template_modal.mode {
4984 TemplateModalMode::List => {
4985 if self.template_modal.focus == TemplateFocus::TemplateList {
4986 let i = match self.template_modal.table_state.selected() {
4987 Some(i) => {
4988 if i >= self
4989 .template_modal
4990 .templates
4991 .len()
4992 .saturating_sub(1)
4993 {
4994 0
4995 } else {
4996 i + 1
4997 }
4998 }
4999 None => 0,
5000 };
5001 self.template_modal.table_state.select(Some(i));
5002 }
5003 }
5004 TemplateModalMode::Create | TemplateModalMode::Edit => {
5005 if self.template_modal.create_focus == CreateFocus::Description {
5007 let event = KeyEvent::new(KeyCode::Down, KeyModifiers::empty());
5008 self.template_modal
5009 .create_description_input
5010 .handle_key(&event, None);
5011 let area_height = 10; self.template_modal
5014 .create_description_input
5015 .ensure_cursor_visible(area_height, 80);
5016 } else {
5017 self.template_modal.next_focus();
5019 }
5020 }
5021 }
5022 }
5023 KeyCode::Char('j')
5024 if self.template_modal.mode == TemplateModalMode::List
5025 && self.template_modal.focus == TemplateFocus::TemplateList
5026 && !self.template_modal.delete_confirm =>
5027 {
5028 let i = match self.template_modal.table_state.selected() {
5029 Some(i) => {
5030 if i >= self.template_modal.templates.len().saturating_sub(1) {
5031 0
5032 } else {
5033 i + 1
5034 }
5035 }
5036 None => 0,
5037 };
5038 self.template_modal.table_state.select(Some(i));
5039 }
5040 KeyCode::Char('k')
5041 if self.template_modal.mode == TemplateModalMode::List
5042 && self.template_modal.focus == TemplateFocus::TemplateList
5043 && !self.template_modal.delete_confirm =>
5044 {
5045 let i = match self.template_modal.table_state.selected() {
5046 Some(i) => {
5047 if i == 0 {
5048 self.template_modal.templates.len().saturating_sub(1)
5049 } else {
5050 i - 1
5051 }
5052 }
5053 None => 0,
5054 };
5055 self.template_modal.table_state.select(Some(i));
5056 }
5057 KeyCode::Char(c)
5058 if self.template_modal.mode == TemplateModalMode::Create
5059 || self.template_modal.mode == TemplateModalMode::Edit =>
5060 {
5061 match self.template_modal.create_focus {
5062 CreateFocus::Name => {
5063 self.template_modal.name_error = None;
5065 let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5066 self.template_modal
5067 .create_name_input
5068 .handle_key(&event, None);
5069 }
5070 CreateFocus::Description => {
5071 let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5072 self.template_modal
5073 .create_description_input
5074 .handle_key(&event, None);
5075 let area_height = 10; self.template_modal
5078 .create_description_input
5079 .ensure_cursor_visible(area_height, 80);
5080 }
5081 CreateFocus::ExactPath => {
5082 let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5083 self.template_modal
5084 .create_exact_path_input
5085 .handle_key(&event, None);
5086 }
5087 CreateFocus::RelativePath => {
5088 let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5089 self.template_modal
5090 .create_relative_path_input
5091 .handle_key(&event, None);
5092 }
5093 CreateFocus::PathPattern => {
5094 let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5095 self.template_modal
5096 .create_path_pattern_input
5097 .handle_key(&event, None);
5098 }
5099 CreateFocus::FilenamePattern => {
5100 let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5101 self.template_modal
5102 .create_filename_pattern_input
5103 .handle_key(&event, None);
5104 }
5105 CreateFocus::SchemaMatch => {
5106 if c == ' ' {
5108 self.template_modal.create_schema_match_enabled =
5109 !self.template_modal.create_schema_match_enabled;
5110 }
5111 }
5112 _ => {}
5113 }
5114 }
5115 KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End
5116 if self.template_modal.mode == TemplateModalMode::Create
5117 || self.template_modal.mode == TemplateModalMode::Edit =>
5118 {
5119 match self.template_modal.create_focus {
5120 CreateFocus::Name => {
5121 self.template_modal
5122 .create_name_input
5123 .handle_key(event, None);
5124 }
5125 CreateFocus::Description => {
5126 self.template_modal
5127 .create_description_input
5128 .handle_key(event, None);
5129 let area_height = 10;
5131 self.template_modal
5132 .create_description_input
5133 .ensure_cursor_visible(area_height, 80);
5134 }
5135 CreateFocus::ExactPath => {
5136 self.template_modal
5137 .create_exact_path_input
5138 .handle_key(event, None);
5139 }
5140 CreateFocus::RelativePath => {
5141 self.template_modal
5142 .create_relative_path_input
5143 .handle_key(event, None);
5144 }
5145 CreateFocus::PathPattern => {
5146 self.template_modal
5147 .create_path_pattern_input
5148 .handle_key(event, None);
5149 }
5150 CreateFocus::FilenamePattern => {
5151 self.template_modal
5152 .create_filename_pattern_input
5153 .handle_key(event, None);
5154 }
5155 _ => {}
5156 }
5157 }
5158 KeyCode::PageUp | KeyCode::PageDown
5159 if self.template_modal.mode == TemplateModalMode::Create
5160 || self.template_modal.mode == TemplateModalMode::Edit =>
5161 {
5162 if self.template_modal.create_focus == CreateFocus::Description {
5165 let lines: Vec<&str> = self
5166 .template_modal
5167 .create_description_input
5168 .value
5169 .lines()
5170 .collect();
5171 let current_line = self.template_modal.create_description_input.cursor_line;
5172 let current_col = self.template_modal.create_description_input.cursor_col;
5173
5174 let target_line = if event.code == KeyCode::PageUp {
5175 current_line.saturating_sub(5)
5176 } else {
5177 (current_line + 5).min(lines.len().saturating_sub(1))
5178 };
5179
5180 if target_line < lines.len() {
5181 let target_line_str = lines.get(target_line).unwrap_or(&"");
5182 let new_col = current_col.min(target_line_str.chars().count());
5183 self.template_modal.create_description_input.cursor = self
5184 .template_modal
5185 .create_description_input
5186 .line_col_to_cursor(target_line, new_col);
5187 self.template_modal
5188 .create_description_input
5189 .update_line_col_from_cursor();
5190 let area_height = 10;
5192 self.template_modal
5193 .create_description_input
5194 .ensure_cursor_visible(area_height, 80);
5195 }
5196 }
5197 }
5198 KeyCode::Backspace
5199 | KeyCode::Delete
5200 | KeyCode::Left
5201 | KeyCode::Right
5202 | KeyCode::Home
5203 | KeyCode::End
5204 if self.template_modal.mode == TemplateModalMode::Create
5205 || self.template_modal.mode == TemplateModalMode::Edit =>
5206 {
5207 match self.template_modal.create_focus {
5208 CreateFocus::Name => {
5209 self.template_modal
5210 .create_name_input
5211 .handle_key(event, None);
5212 }
5213 CreateFocus::Description => {
5214 self.template_modal
5215 .create_description_input
5216 .handle_key(event, None);
5217 let area_height = 10;
5219 self.template_modal
5220 .create_description_input
5221 .ensure_cursor_visible(area_height, 80);
5222 }
5223 CreateFocus::ExactPath => {
5224 self.template_modal
5225 .create_exact_path_input
5226 .handle_key(event, None);
5227 }
5228 CreateFocus::RelativePath => {
5229 self.template_modal
5230 .create_relative_path_input
5231 .handle_key(event, None);
5232 }
5233 CreateFocus::PathPattern => {
5234 self.template_modal
5235 .create_path_pattern_input
5236 .handle_key(event, None);
5237 }
5238 CreateFocus::FilenamePattern => {
5239 self.template_modal
5240 .create_filename_pattern_input
5241 .handle_key(event, None);
5242 }
5243 _ => {}
5244 }
5245 }
5246 _ => {}
5247 }
5248 return None;
5249 }
5250
5251 if self.input_mode == InputMode::Editing {
5252 if self.input_type == Some(InputType::Search) {
5253 const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5254 const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5255
5256 if self.query_focus == QueryFocus::TabBar && event.is_press() {
5257 if event.code == KeyCode::BackTab
5258 || (event.code == KeyCode::Tab
5259 && !event.modifiers.contains(KeyModifiers::SHIFT))
5260 {
5261 self.query_focus = QueryFocus::Input;
5262 if let Some(state) = &self.data_table_state {
5263 if self.query_tab == QueryTab::SqlLike {
5264 self.query_input.value = state.get_active_query().to_string();
5265 self.query_input.cursor = self.query_input.value.chars().count();
5266 self.sql_input.set_focused(false);
5267 self.fuzzy_input.set_focused(false);
5268 self.query_input.set_focused(true);
5269 } else if self.query_tab == QueryTab::Fuzzy {
5270 self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5271 self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5272 self.query_input.set_focused(false);
5273 self.sql_input.set_focused(false);
5274 self.fuzzy_input.set_focused(true);
5275 } else if self.query_tab == QueryTab::Sql {
5276 self.sql_input.value = state.get_active_sql_query().to_string();
5277 self.sql_input.cursor = self.sql_input.value.chars().count();
5278 self.query_input.set_focused(false);
5279 self.fuzzy_input.set_focused(false);
5280 self.sql_input.set_focused(true);
5281 }
5282 }
5283 return None;
5284 }
5285 if RIGHT_KEYS.contains(&event.code) {
5286 self.query_tab = self.query_tab.next();
5287 if let Some(state) = &self.data_table_state {
5288 if self.query_tab == QueryTab::SqlLike {
5289 self.query_input.value = state.get_active_query().to_string();
5290 self.query_input.cursor = self.query_input.value.chars().count();
5291 } else if self.query_tab == QueryTab::Fuzzy {
5292 self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5293 self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5294 } else if self.query_tab == QueryTab::Sql {
5295 self.sql_input.value = state.get_active_sql_query().to_string();
5296 self.sql_input.cursor = self.sql_input.value.chars().count();
5297 }
5298 }
5299 self.query_input.set_focused(false);
5300 self.sql_input.set_focused(false);
5301 self.fuzzy_input.set_focused(false);
5302 return None;
5303 }
5304 if LEFT_KEYS.contains(&event.code) {
5305 self.query_tab = self.query_tab.prev();
5306 if let Some(state) = &self.data_table_state {
5307 if self.query_tab == QueryTab::SqlLike {
5308 self.query_input.value = state.get_active_query().to_string();
5309 self.query_input.cursor = self.query_input.value.chars().count();
5310 } else if self.query_tab == QueryTab::Fuzzy {
5311 self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5312 self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5313 } else if self.query_tab == QueryTab::Sql {
5314 self.sql_input.value = state.get_active_sql_query().to_string();
5315 self.sql_input.cursor = self.sql_input.value.chars().count();
5316 }
5317 }
5318 self.query_input.set_focused(false);
5319 self.sql_input.set_focused(false);
5320 self.fuzzy_input.set_focused(false);
5321 return None;
5322 }
5323 if event.code == KeyCode::Esc {
5324 self.query_input.clear();
5325 self.sql_input.clear();
5326 self.fuzzy_input.clear();
5327 self.query_input.set_focused(false);
5328 self.sql_input.set_focused(false);
5329 self.fuzzy_input.set_focused(false);
5330 self.input_mode = InputMode::Normal;
5331 self.input_type = None;
5332 if let Some(state) = &mut self.data_table_state {
5333 state.error = None;
5334 state.suppress_error_display = false;
5335 }
5336 return None;
5337 }
5338 return None;
5339 }
5340
5341 if event.is_press()
5342 && event.code == KeyCode::Tab
5343 && !event.modifiers.contains(KeyModifiers::SHIFT)
5344 {
5345 self.query_focus = QueryFocus::TabBar;
5346 self.query_input.set_focused(false);
5347 self.sql_input.set_focused(false);
5348 self.fuzzy_input.set_focused(false);
5349 return None;
5350 }
5351
5352 if self.query_focus != QueryFocus::Input {
5353 return None;
5354 }
5355
5356 if self.query_tab == QueryTab::Sql {
5357 self.query_input.set_focused(false);
5358 self.fuzzy_input.set_focused(false);
5359 self.sql_input.set_focused(true);
5360 let result = self.sql_input.handle_key(event, Some(&self.cache));
5361 match result {
5362 TextInputEvent::Submit => {
5363 let _ = self.sql_input.save_to_history(&self.cache);
5364 let sql = self.sql_input.value.clone();
5365 self.sql_input.set_focused(false);
5366 return Some(AppEvent::SqlSearch(sql));
5367 }
5368 TextInputEvent::Cancel => {
5369 self.sql_input.clear();
5370 self.sql_input.set_focused(false);
5371 self.input_mode = InputMode::Normal;
5372 self.input_type = None;
5373 if let Some(state) = &mut self.data_table_state {
5374 state.error = None;
5375 state.suppress_error_display = false;
5376 }
5377 }
5378 TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5379 }
5380 return None;
5381 }
5382
5383 if self.query_tab == QueryTab::Fuzzy {
5384 self.query_input.set_focused(false);
5385 self.sql_input.set_focused(false);
5386 self.fuzzy_input.set_focused(true);
5387 let result = self.fuzzy_input.handle_key(event, Some(&self.cache));
5388 match result {
5389 TextInputEvent::Submit => {
5390 let _ = self.fuzzy_input.save_to_history(&self.cache);
5391 let query = self.fuzzy_input.value.clone();
5392 self.fuzzy_input.set_focused(false);
5393 return Some(AppEvent::FuzzySearch(query));
5394 }
5395 TextInputEvent::Cancel => {
5396 self.fuzzy_input.clear();
5397 self.fuzzy_input.set_focused(false);
5398 self.input_mode = InputMode::Normal;
5399 self.input_type = None;
5400 if let Some(state) = &mut self.data_table_state {
5401 state.error = None;
5402 state.suppress_error_display = false;
5403 }
5404 }
5405 TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5406 }
5407 return None;
5408 }
5409
5410 if self.query_tab != QueryTab::SqlLike {
5411 return None;
5412 }
5413
5414 self.sql_input.set_focused(false);
5415 self.fuzzy_input.set_focused(false);
5416 self.query_input.set_focused(true);
5417 let result = self.query_input.handle_key(event, Some(&self.cache));
5418
5419 match result {
5420 TextInputEvent::Submit => {
5421 let _ = self.query_input.save_to_history(&self.cache);
5423 let query = self.query_input.value.clone();
5424 self.query_input.set_focused(false);
5425 return Some(AppEvent::Search(query));
5426 }
5427 TextInputEvent::Cancel => {
5428 self.query_input.clear();
5430 self.query_input.set_focused(false);
5431 self.input_mode = InputMode::Normal;
5432 if let Some(state) = &mut self.data_table_state {
5433 state.error = None;
5435 state.suppress_error_display = false;
5436 }
5437 }
5438 TextInputEvent::HistoryChanged => {
5439 }
5441 TextInputEvent::None => {
5442 }
5444 }
5445 return None;
5446 }
5447
5448 if self.input_type == Some(InputType::GoToLine) {
5450 self.query_input.set_focused(true);
5451 let result = self.query_input.handle_key(event, None);
5452 match result {
5453 TextInputEvent::Submit => {
5454 let value = self.query_input.value.trim().to_string();
5455 self.query_input.clear();
5456 self.query_input.set_focused(false);
5457 self.input_mode = InputMode::Normal;
5458 self.input_type = None;
5459 if let Some(state) = &mut self.data_table_state {
5460 if let Ok(display_line) = value.parse::<usize>() {
5461 let row_index =
5462 display_line.saturating_sub(state.row_start_index());
5463 let would_collect = state.scroll_would_trigger_collect(
5464 row_index as i64 - state.start_row as i64,
5465 );
5466 if would_collect {
5467 self.busy = true;
5468 return Some(AppEvent::GoToLine(row_index));
5469 }
5470 state.scroll_to_row_centered(row_index);
5471 }
5472 }
5473 }
5474 TextInputEvent::Cancel => {
5475 self.query_input.clear();
5476 self.query_input.set_focused(false);
5477 self.input_mode = InputMode::Normal;
5478 self.input_type = None;
5479 }
5480 TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5481 }
5482 return None;
5483 }
5484
5485 return None;
5488 }
5489
5490 const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5491
5492 const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5493
5494 const DOWN_KEYS: [KeyCode; 2] = [KeyCode::Down, KeyCode::Char('j')];
5495
5496 const UP_KEYS: [KeyCode; 2] = [KeyCode::Up, KeyCode::Char('k')];
5497
5498 match event.code {
5499 KeyCode::Char('q') | KeyCode::Char('Q') => Some(AppEvent::Exit),
5500 KeyCode::Char('c') if event.modifiers.contains(KeyModifiers::CONTROL) => {
5501 Some(AppEvent::Exit)
5502 }
5503 KeyCode::Char('R') => Some(AppEvent::Reset),
5504 KeyCode::Char('N') => {
5505 if let Some(ref mut state) = self.data_table_state {
5506 state.toggle_row_numbers();
5507 }
5508 None
5509 }
5510 KeyCode::Esc => {
5511 if let Some(ref mut state) = self.data_table_state {
5513 if state.is_drilled_down() {
5514 let _ = state.drill_up();
5515 return None;
5516 }
5517 }
5518 None
5521 }
5522 code if RIGHT_KEYS.contains(&code) => {
5523 if let Some(ref mut state) = self.data_table_state {
5524 state.scroll_right();
5525 if self.debug.enabled {
5526 self.debug.last_action = "scroll_right".to_string();
5527 }
5528 }
5529 None
5530 }
5531 code if LEFT_KEYS.contains(&code) => {
5532 if let Some(ref mut state) = self.data_table_state {
5533 state.scroll_left();
5534 if self.debug.enabled {
5535 self.debug.last_action = "scroll_left".to_string();
5536 }
5537 }
5538 None
5539 }
5540 code if event.is_press() && DOWN_KEYS.contains(&code) => {
5541 let would_collect = self
5542 .data_table_state
5543 .as_ref()
5544 .map(|s| s.scroll_would_trigger_collect(1))
5545 .unwrap_or(false);
5546 if would_collect {
5547 self.busy = true;
5548 Some(AppEvent::DoScrollNext)
5549 } else {
5550 if let Some(ref mut s) = self.data_table_state {
5551 s.select_next();
5552 }
5553 None
5554 }
5555 }
5556 code if event.is_press() && UP_KEYS.contains(&code) => {
5557 let would_collect = self
5558 .data_table_state
5559 .as_ref()
5560 .map(|s| s.scroll_would_trigger_collect(-1))
5561 .unwrap_or(false);
5562 if would_collect {
5563 self.busy = true;
5564 Some(AppEvent::DoScrollPrev)
5565 } else {
5566 if let Some(ref mut s) = self.data_table_state {
5567 s.select_previous();
5568 }
5569 None
5570 }
5571 }
5572 KeyCode::PageDown if event.is_press() => {
5573 let would_collect = self
5574 .data_table_state
5575 .as_ref()
5576 .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
5577 .unwrap_or(false);
5578 if would_collect {
5579 self.busy = true;
5580 Some(AppEvent::DoScrollDown)
5581 } else {
5582 if let Some(ref mut s) = self.data_table_state {
5583 s.page_down();
5584 }
5585 None
5586 }
5587 }
5588 KeyCode::Home if event.is_press() => {
5589 if let Some(ref mut state) = self.data_table_state {
5590 if state.start_row > 0 {
5591 state.scroll_to(0);
5592 }
5593 state.table_state.select(Some(0));
5594 }
5595 None
5596 }
5597 KeyCode::End if event.is_press() => {
5598 if self.data_table_state.is_some() {
5599 self.busy = true;
5600 Some(AppEvent::DoScrollEnd)
5601 } else {
5602 None
5603 }
5604 }
5605 KeyCode::Char('G') if event.is_press() => {
5606 if self.data_table_state.is_some() {
5607 self.busy = true;
5608 Some(AppEvent::DoScrollEnd)
5609 } else {
5610 None
5611 }
5612 }
5613 KeyCode::Char('f')
5614 if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5615 {
5616 let would_collect = self
5617 .data_table_state
5618 .as_ref()
5619 .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
5620 .unwrap_or(false);
5621 if would_collect {
5622 self.busy = true;
5623 Some(AppEvent::DoScrollDown)
5624 } else {
5625 if let Some(ref mut s) = self.data_table_state {
5626 s.page_down();
5627 }
5628 None
5629 }
5630 }
5631 KeyCode::Char('b')
5632 if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5633 {
5634 let would_collect = self
5635 .data_table_state
5636 .as_ref()
5637 .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5638 .unwrap_or(false);
5639 if would_collect {
5640 self.busy = true;
5641 Some(AppEvent::DoScrollUp)
5642 } else {
5643 if let Some(ref mut s) = self.data_table_state {
5644 s.page_up();
5645 }
5646 None
5647 }
5648 }
5649 KeyCode::Char('d')
5650 if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5651 {
5652 let half = self
5653 .data_table_state
5654 .as_ref()
5655 .map(|s| (s.visible_rows / 2).max(1) as i64)
5656 .unwrap_or(1);
5657 let would_collect = self
5658 .data_table_state
5659 .as_ref()
5660 .map(|s| s.scroll_would_trigger_collect(half))
5661 .unwrap_or(false);
5662 if would_collect {
5663 self.busy = true;
5664 Some(AppEvent::DoScrollHalfDown)
5665 } else {
5666 if let Some(ref mut s) = self.data_table_state {
5667 s.half_page_down();
5668 }
5669 None
5670 }
5671 }
5672 KeyCode::Char('u')
5673 if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5674 {
5675 let half = self
5676 .data_table_state
5677 .as_ref()
5678 .map(|s| (s.visible_rows / 2).max(1) as i64)
5679 .unwrap_or(1);
5680 let would_collect = self
5681 .data_table_state
5682 .as_ref()
5683 .map(|s| s.scroll_would_trigger_collect(-half))
5684 .unwrap_or(false);
5685 if would_collect {
5686 self.busy = true;
5687 Some(AppEvent::DoScrollHalfUp)
5688 } else {
5689 if let Some(ref mut s) = self.data_table_state {
5690 s.half_page_up();
5691 }
5692 None
5693 }
5694 }
5695 KeyCode::PageUp if event.is_press() => {
5696 let would_collect = self
5697 .data_table_state
5698 .as_ref()
5699 .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5700 .unwrap_or(false);
5701 if would_collect {
5702 self.busy = true;
5703 Some(AppEvent::DoScrollUp)
5704 } else {
5705 if let Some(ref mut s) = self.data_table_state {
5706 s.page_up();
5707 }
5708 None
5709 }
5710 }
5711 KeyCode::Enter if event.is_press() => {
5712 if self.input_mode == InputMode::Normal {
5714 if let Some(ref mut state) = self.data_table_state {
5715 if state.is_grouped() && !state.is_drilled_down() {
5716 if let Some(selected) = state.table_state.selected() {
5717 let group_index = state.start_row + selected;
5718 let _ = state.drill_down_into_group(group_index);
5719 }
5720 }
5721 }
5722 }
5723 None
5724 }
5725 KeyCode::Tab if event.is_press() => {
5726 self.focus = (self.focus + 1) % 2;
5727 None
5728 }
5729 KeyCode::BackTab if event.is_press() => {
5730 self.focus = (self.focus + 1) % 2;
5731 None
5732 }
5733 KeyCode::Char('i') if event.is_press() => {
5734 if self.data_table_state.is_some() {
5735 self.info_modal.open();
5736 self.input_mode = InputMode::Info;
5737 if self.path.is_some()
5739 && self.original_file_format == Some(ExportFormat::Parquet)
5740 && self.parquet_metadata_cache.is_none()
5741 {
5742 self.busy = true;
5743 return Some(AppEvent::DoLoadParquetMetadata);
5744 }
5745 }
5746 None
5747 }
5748 KeyCode::Char('/') => {
5749 self.input_mode = InputMode::Editing;
5750 self.input_type = Some(InputType::Search);
5751 self.query_tab = QueryTab::SqlLike;
5752 self.query_focus = QueryFocus::Input;
5753 if let Some(state) = &mut self.data_table_state {
5754 self.query_input.value = state.active_query.clone();
5755 self.query_input.cursor = self.query_input.value.chars().count();
5756 self.sql_input.value = state.get_active_sql_query().to_string();
5757 self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5758 self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5759 self.sql_input.cursor = self.sql_input.value.chars().count();
5760 state.suppress_error_display = true;
5761 } else {
5762 self.query_input.clear();
5763 self.sql_input.clear();
5764 self.fuzzy_input.clear();
5765 }
5766 self.sql_input.set_focused(false);
5767 self.fuzzy_input.set_focused(false);
5768 self.query_input.set_focused(true);
5769 None
5770 }
5771 KeyCode::Char(':') if event.is_press() => {
5772 if self.data_table_state.is_some() {
5773 self.input_mode = InputMode::Editing;
5774 self.input_type = Some(InputType::GoToLine);
5775 self.query_input.value.clear();
5776 self.query_input.cursor = 0;
5777 self.query_input.set_focused(true);
5778 }
5779 None
5780 }
5781 KeyCode::Char('T') => {
5782 if let Some(ref state) = self.data_table_state {
5784 if let Some(ref path) = self.path {
5785 if let Some(template) =
5786 self.template_manager.get_most_relevant(path, &state.schema)
5787 {
5788 if let Err(e) = self.apply_template(&template) {
5790 self.error_modal
5792 .show(format!("Error applying template: {}", e));
5793 }
5794 }
5795 }
5796 }
5797 None
5798 }
5799 KeyCode::Char('t') => {
5800 if let Some(ref state) = self.data_table_state {
5802 if let Some(ref path) = self.path {
5803 self.template_modal.templates = self
5805 .template_manager
5806 .find_relevant_templates(path, &state.schema);
5807 self.template_modal.table_state.select(
5808 if self.template_modal.templates.is_empty() {
5809 None
5810 } else {
5811 Some(0)
5812 },
5813 );
5814 self.template_modal.active = true;
5815 self.template_modal.mode = TemplateModalMode::List;
5816 self.template_modal.focus = TemplateFocus::TemplateList;
5817 }
5818 }
5819 None
5820 }
5821 KeyCode::Char('s') => {
5822 if let Some(state) = &self.data_table_state {
5823 let headers: Vec<String> =
5824 state.schema.iter_names().map(|s| s.to_string()).collect();
5825 let locked_count = state.locked_columns_count();
5826
5827 let mut existing_columns: std::collections::HashMap<String, SortColumn> = self
5829 .sort_filter_modal
5830 .sort
5831 .columns
5832 .iter()
5833 .map(|c| (c.name.clone(), c.clone()))
5834 .collect();
5835 self.sort_filter_modal.sort.columns = headers
5836 .iter()
5837 .enumerate()
5838 .map(|(i, h)| {
5839 if let Some(mut col) = existing_columns.remove(h) {
5840 col.display_order = i;
5841 col.is_locked = i < locked_count;
5842 col.is_to_be_locked = false;
5843 col
5844 } else {
5845 SortColumn {
5846 name: h.clone(),
5847 sort_order: None,
5848 display_order: i,
5849 is_locked: i < locked_count,
5850 is_to_be_locked: false,
5851 is_visible: true,
5852 }
5853 }
5854 })
5855 .collect();
5856 self.sort_filter_modal.sort.filter_input.clear();
5857 self.sort_filter_modal.sort.focus = SortFocus::ColumnList;
5858
5859 self.sort_filter_modal.filter.available_columns = state.headers();
5861 if !self.sort_filter_modal.filter.available_columns.is_empty() {
5862 self.sort_filter_modal.filter.new_column_idx =
5863 self.sort_filter_modal.filter.new_column_idx.min(
5864 self.sort_filter_modal
5865 .filter
5866 .available_columns
5867 .len()
5868 .saturating_sub(1),
5869 );
5870 } else {
5871 self.sort_filter_modal.filter.new_column_idx = 0;
5872 }
5873
5874 self.sort_filter_modal.open(self.history_limit, &self.theme);
5875 self.input_mode = InputMode::SortFilter;
5876 }
5877 None
5878 }
5879 KeyCode::Char('r') => {
5880 if let Some(state) = &mut self.data_table_state {
5881 state.reverse();
5882 }
5883 None
5884 }
5885 KeyCode::Char('a') => {
5886 if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
5888 self.analysis_modal.open();
5889 }
5890 None
5891 }
5892 KeyCode::Char('c') => {
5893 if let Some(state) = &self.data_table_state {
5894 if self.input_mode == InputMode::Normal {
5895 let numeric_columns: Vec<String> = state
5896 .schema
5897 .iter()
5898 .filter(|(_, dtype)| dtype.is_numeric())
5899 .map(|(name, _)| name.to_string())
5900 .collect();
5901 let datetime_columns: Vec<String> = state
5902 .schema
5903 .iter()
5904 .filter(|(_, dtype)| {
5905 matches!(
5906 dtype,
5907 DataType::Datetime(_, _) | DataType::Date | DataType::Time
5908 )
5909 })
5910 .map(|(name, _)| name.to_string())
5911 .collect();
5912 self.chart_modal.open(
5913 &numeric_columns,
5914 &datetime_columns,
5915 self.app_config.chart.row_limit,
5916 );
5917 self.chart_modal.x_input =
5918 std::mem::take(&mut self.chart_modal.x_input).with_theme(&self.theme);
5919 self.chart_modal.y_input =
5920 std::mem::take(&mut self.chart_modal.y_input).with_theme(&self.theme);
5921 self.chart_modal.hist_input =
5922 std::mem::take(&mut self.chart_modal.hist_input)
5923 .with_theme(&self.theme);
5924 self.chart_modal.box_input =
5925 std::mem::take(&mut self.chart_modal.box_input).with_theme(&self.theme);
5926 self.chart_modal.kde_input =
5927 std::mem::take(&mut self.chart_modal.kde_input).with_theme(&self.theme);
5928 self.chart_modal.heatmap_x_input =
5929 std::mem::take(&mut self.chart_modal.heatmap_x_input)
5930 .with_theme(&self.theme);
5931 self.chart_modal.heatmap_y_input =
5932 std::mem::take(&mut self.chart_modal.heatmap_y_input)
5933 .with_theme(&self.theme);
5934 self.chart_cache.clear();
5935 self.input_mode = InputMode::Chart;
5936 }
5937 }
5938 None
5939 }
5940 KeyCode::Char('p') => {
5941 if let Some(state) = &self.data_table_state {
5942 if self.input_mode == InputMode::Normal {
5943 self.pivot_melt_modal.available_columns =
5944 state.schema.iter_names().map(|s| s.to_string()).collect();
5945 self.pivot_melt_modal.column_dtypes = state
5946 .schema
5947 .iter()
5948 .map(|(n, d)| (n.to_string(), d.clone()))
5949 .collect();
5950 self.pivot_melt_modal.open(self.history_limit, &self.theme);
5951 self.input_mode = InputMode::PivotMelt;
5952 }
5953 }
5954 None
5955 }
5956 KeyCode::Char('e') => {
5957 if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
5958 let config_delimiter = AppConfig::load(APP_NAME)
5960 .ok()
5961 .and_then(|config| config.file_loading.delimiter);
5962 self.export_modal.open(
5963 self.original_file_format,
5964 self.history_limit,
5965 &self.theme,
5966 self.original_file_delimiter,
5967 config_delimiter,
5968 );
5969 self.input_mode = InputMode::Export;
5970 }
5971 None
5972 }
5973 _ => None,
5974 }
5975 }
5976
5977 pub fn event(&mut self, event: &AppEvent) -> Option<AppEvent> {
5978 self.debug.num_events += 1;
5979
5980 match event {
5981 AppEvent::Key(key) => {
5982 let is_column_scroll = matches!(
5983 key.code,
5984 KeyCode::Left | KeyCode::Right | KeyCode::Char('h') | KeyCode::Char('l')
5985 );
5986 let is_help_key = key.code == KeyCode::F(1);
5987 if self.busy && !is_column_scroll && !is_help_key && !self.confirmation_modal.active
5989 {
5990 return None;
5991 }
5992 self.key(key)
5993 }
5994 AppEvent::Open(paths, options) => {
5995 if paths.is_empty() {
5996 return Some(AppEvent::Crash("No paths provided".to_string()));
5997 }
5998 #[cfg(feature = "http")]
5999 if let Some(ref p) = self.http_temp_path.take() {
6000 let _ = std::fs::remove_file(p);
6001 }
6002 self.busy = true;
6003 let first = &paths[0];
6004 let file_size = match source::input_source(first) {
6005 source::InputSource::Local(_) => {
6006 std::fs::metadata(first).map(|m| m.len()).unwrap_or(0)
6007 }
6008 source::InputSource::S3(_)
6009 | source::InputSource::Gcs(_)
6010 | source::InputSource::Http(_) => 0,
6011 };
6012 let path_str = first.as_os_str().to_string_lossy();
6013 let _is_partitioned_path = paths.len() == 1
6014 && options.hive
6015 && (first.is_dir() || path_str.contains('*') || path_str.contains("**"));
6016 let phase = "Scanning input";
6017
6018 self.loading_state = LoadingState::Loading {
6019 file_path: Some(first.clone()),
6020 file_size,
6021 current_phase: phase.to_string(),
6022 progress_percent: 10,
6023 };
6024
6025 Some(AppEvent::DoLoadScanPaths(paths.clone(), options.clone()))
6026 }
6027 AppEvent::OpenLazyFrame(lf, options) => {
6028 self.busy = true;
6029 self.loading_state = LoadingState::Loading {
6030 file_path: None,
6031 file_size: 0,
6032 current_phase: "Scanning input".to_string(),
6033 progress_percent: 10,
6034 };
6035 Some(AppEvent::DoLoadSchema(lf.clone(), None, options.clone()))
6036 }
6037 AppEvent::DoLoadScanPaths(paths, options) => {
6038 let first = &paths[0];
6039 let src = source::input_source(first);
6040 if paths.len() > 1 {
6041 match &src {
6042 source::InputSource::S3(_) => {
6043 return Some(AppEvent::Crash(
6044 "Only one S3 URL at a time. Open a single s3:// path.".to_string(),
6045 ));
6046 }
6047 source::InputSource::Gcs(_) => {
6048 return Some(AppEvent::Crash(
6049 "Only one GCS URL at a time. Open a single gs:// path.".to_string(),
6050 ));
6051 }
6052 source::InputSource::Http(_) => {
6053 return Some(AppEvent::Crash(
6054 "Only one HTTP/HTTPS URL at a time. Open a single URL.".to_string(),
6055 ));
6056 }
6057 source::InputSource::Local(_) => {}
6058 }
6059 }
6060 let compression = options
6061 .compression
6062 .or_else(|| CompressionFormat::from_extension(first));
6063 let is_csv = first
6064 .file_stem()
6065 .and_then(|stem| stem.to_str())
6066 .map(|stem| {
6067 stem.ends_with(".csv")
6068 || first
6069 .extension()
6070 .and_then(|e| e.to_str())
6071 .map(|e| e.eq_ignore_ascii_case("csv"))
6072 .unwrap_or(false)
6073 })
6074 .unwrap_or(false);
6075 let is_compressed_csv = matches!(src, source::InputSource::Local(_))
6076 && paths.len() == 1
6077 && compression.is_some()
6078 && is_csv;
6079 if is_compressed_csv {
6080 if let LoadingState::Loading {
6081 file_path,
6082 file_size,
6083 ..
6084 } = &self.loading_state
6085 {
6086 self.loading_state = LoadingState::Loading {
6087 file_path: file_path.clone(),
6088 file_size: *file_size,
6089 current_phase: "Decompressing".to_string(),
6090 progress_percent: 30,
6091 };
6092 }
6093 Some(AppEvent::DoLoad(paths.clone(), options.clone()))
6094 } else {
6095 #[cfg(feature = "http")]
6096 if let source::InputSource::Http(ref url) = src {
6097 let size = Self::fetch_remote_size_http(url).unwrap_or(None);
6098 let size_str = size
6099 .map(Self::format_bytes)
6100 .unwrap_or_else(|| "unknown".to_string());
6101 let dest_dir = options
6102 .temp_dir
6103 .as_deref()
6104 .map(|p| p.display().to_string())
6105 .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6106 let message = format!(
6107 "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6108 url, size_str, dest_dir
6109 );
6110 self.pending_download = Some(PendingDownload::Http {
6111 url: url.clone(),
6112 size,
6113 options: options.clone(),
6114 });
6115 self.confirmation_modal.show(message);
6116 return None;
6117 }
6118 #[cfg(feature = "cloud")]
6119 if let source::InputSource::S3(ref url) = src {
6120 let full = format!("s3://{url}");
6121 let (_, ext) = source::url_path_extension(&full);
6122 let is_glob = full.contains('*') || full.ends_with('/');
6123 if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6124 let size =
6125 Self::fetch_remote_size_s3(&full, &self.app_config.cloud, options)
6126 .unwrap_or(None);
6127 let size_str = size
6128 .map(Self::format_bytes)
6129 .unwrap_or_else(|| "unknown".to_string());
6130 let dest_dir = options
6131 .temp_dir
6132 .as_deref()
6133 .map(|p| p.display().to_string())
6134 .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6135 let message = format!(
6136 "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6137 full, size_str, dest_dir
6138 );
6139 self.pending_download = Some(PendingDownload::S3 {
6140 url: full,
6141 size,
6142 options: options.clone(),
6143 });
6144 self.confirmation_modal.show(message);
6145 return None;
6146 }
6147 }
6148 #[cfg(feature = "cloud")]
6149 if let source::InputSource::Gcs(ref url) = src {
6150 let full = format!("gs://{url}");
6151 let (_, ext) = source::url_path_extension(&full);
6152 let is_glob = full.contains('*') || full.ends_with('/');
6153 if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6154 let size = Self::fetch_remote_size_gcs(&full, options).unwrap_or(None);
6155 let size_str = size
6156 .map(Self::format_bytes)
6157 .unwrap_or_else(|| "unknown".to_string());
6158 let dest_dir = options
6159 .temp_dir
6160 .as_deref()
6161 .map(|p| p.display().to_string())
6162 .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6163 let message = format!(
6164 "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6165 full, size_str, dest_dir
6166 );
6167 self.pending_download = Some(PendingDownload::Gcs {
6168 url: full,
6169 size,
6170 options: options.clone(),
6171 });
6172 self.confirmation_modal.show(message);
6173 return None;
6174 }
6175 }
6176 let first = paths[0].clone();
6177 #[allow(clippy::needless_borrow)]
6178 match self.build_lazyframe_from_paths(&paths, options) {
6179 Ok(lf) => {
6180 if let LoadingState::Loading {
6181 file_path,
6182 file_size,
6183 ..
6184 } = &self.loading_state
6185 {
6186 self.loading_state = LoadingState::Loading {
6187 file_path: file_path.clone(),
6188 file_size: *file_size,
6189 current_phase: "Caching schema".to_string(),
6190 progress_percent: 40,
6191 };
6192 }
6193 Some(AppEvent::DoLoadSchema(
6194 Box::new(lf),
6195 Some(first),
6196 options.clone(),
6197 ))
6198 }
6199 Err(e) => {
6200 self.loading_state = LoadingState::Idle;
6201 self.busy = false;
6202 self.drain_keys_on_next_loop = true;
6203 let msg = crate::error_display::user_message_from_report(
6204 &e,
6205 paths.first().map(|p| p.as_path()),
6206 );
6207 Some(AppEvent::Crash(msg))
6208 }
6209 }
6210 }
6211 }
6212 #[cfg(feature = "http")]
6213 AppEvent::DoDownloadHttp(url, options) => {
6214 let (_, ext) = source::url_path_extension(url.as_str());
6215 match Self::download_http_to_temp(
6216 url.as_str(),
6217 options.temp_dir.as_deref(),
6218 ext.as_deref(),
6219 ) {
6220 Ok(temp_path) => {
6221 self.http_temp_path = Some(temp_path.clone());
6222 if let LoadingState::Loading {
6223 file_path,
6224 file_size,
6225 ..
6226 } = &self.loading_state
6227 {
6228 self.loading_state = LoadingState::Loading {
6229 file_path: file_path.clone(),
6230 file_size: *file_size,
6231 current_phase: "Scanning".to_string(),
6232 progress_percent: 30,
6233 };
6234 }
6235 Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6236 }
6237 Err(e) => {
6238 self.loading_state = LoadingState::Idle;
6239 self.busy = false;
6240 self.drain_keys_on_next_loop = true;
6241 let msg = crate::error_display::user_message_from_report(&e, None);
6242 Some(AppEvent::Crash(msg))
6243 }
6244 }
6245 }
6246 #[cfg(feature = "cloud")]
6247 AppEvent::DoDownloadS3ToTemp(s3_url, options) => {
6248 match Self::download_s3_to_temp(s3_url, &self.app_config.cloud, options) {
6249 Ok(temp_path) => {
6250 self.http_temp_path = Some(temp_path.clone());
6251 if let LoadingState::Loading {
6252 file_path,
6253 file_size,
6254 ..
6255 } = &self.loading_state
6256 {
6257 self.loading_state = LoadingState::Loading {
6258 file_path: file_path.clone(),
6259 file_size: *file_size,
6260 current_phase: "Scanning".to_string(),
6261 progress_percent: 30,
6262 };
6263 }
6264 Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6265 }
6266 Err(e) => {
6267 self.loading_state = LoadingState::Idle;
6268 self.busy = false;
6269 self.drain_keys_on_next_loop = true;
6270 let msg = crate::error_display::user_message_from_report(&e, None);
6271 Some(AppEvent::Crash(msg))
6272 }
6273 }
6274 }
6275 #[cfg(feature = "cloud")]
6276 AppEvent::DoDownloadGcsToTemp(gs_url, options) => {
6277 match Self::download_gcs_to_temp(gs_url, options) {
6278 Ok(temp_path) => {
6279 self.http_temp_path = Some(temp_path.clone());
6280 if let LoadingState::Loading {
6281 file_path,
6282 file_size,
6283 ..
6284 } = &self.loading_state
6285 {
6286 self.loading_state = LoadingState::Loading {
6287 file_path: file_path.clone(),
6288 file_size: *file_size,
6289 current_phase: "Scanning".to_string(),
6290 progress_percent: 30,
6291 };
6292 }
6293 Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6294 }
6295 Err(e) => {
6296 self.loading_state = LoadingState::Idle;
6297 self.busy = false;
6298 self.drain_keys_on_next_loop = true;
6299 let msg = crate::error_display::user_message_from_report(&e, None);
6300 Some(AppEvent::Crash(msg))
6301 }
6302 }
6303 }
6304 #[cfg(any(feature = "http", feature = "cloud"))]
6305 AppEvent::DoLoadFromHttpTemp(temp_path, options) => {
6306 self.http_temp_path = Some(temp_path.clone());
6307 let display_path = match &self.loading_state {
6308 LoadingState::Loading { file_path, .. } => file_path.clone(),
6309 _ => None,
6310 };
6311 if let LoadingState::Loading {
6312 file_path,
6313 file_size,
6314 ..
6315 } = &self.loading_state
6316 {
6317 self.loading_state = LoadingState::Loading {
6318 file_path: file_path.clone(),
6319 file_size: *file_size,
6320 current_phase: "Scanning".to_string(),
6321 progress_percent: 30,
6322 };
6323 }
6324 #[allow(clippy::cloned_ref_to_slice_refs)]
6325 match self.build_lazyframe_from_paths(&[temp_path.clone()], options) {
6326 Ok(lf) => {
6327 if let LoadingState::Loading {
6328 file_path,
6329 file_size,
6330 ..
6331 } = &self.loading_state
6332 {
6333 self.loading_state = LoadingState::Loading {
6334 file_path: file_path.clone(),
6335 file_size: *file_size,
6336 current_phase: "Caching schema".to_string(),
6337 progress_percent: 40,
6338 };
6339 }
6340 Some(AppEvent::DoLoadSchema(
6341 Box::new(lf),
6342 display_path,
6343 options.clone(),
6344 ))
6345 }
6346 Err(e) => {
6347 self.loading_state = LoadingState::Idle;
6348 self.busy = false;
6349 self.drain_keys_on_next_loop = true;
6350 let msg = crate::error_display::user_message_from_report(
6351 &e,
6352 Some(temp_path.as_path()),
6353 );
6354 Some(AppEvent::Crash(msg))
6355 }
6356 }
6357 }
6358 AppEvent::DoLoadSchema(lf, path, options) => {
6359 if let LoadingState::Loading {
6361 file_path,
6362 file_size,
6363 ..
6364 } = &self.loading_state
6365 {
6366 self.loading_state = LoadingState::Loading {
6367 file_path: file_path.clone(),
6368 file_size: *file_size,
6369 current_phase: "Caching schema".to_string(),
6370 progress_percent: 40,
6371 };
6372 }
6373 Some(AppEvent::DoLoadSchemaBlocking(
6374 lf.clone(),
6375 path.clone(),
6376 options.clone(),
6377 ))
6378 }
6379 AppEvent::DoLoadSchemaBlocking(lf, path, options) => {
6380 self.debug.schema_load = None;
6381 if options.single_spine_schema
6383 && path.as_ref().is_some_and(|p| p.is_dir() && options.hive)
6384 {
6385 let p = path.as_ref().expect("path set by caller");
6386 if let Ok((merged_schema, partition_columns)) =
6387 DataTableState::schema_from_one_hive_parquet(p)
6388 {
6389 if let Ok(lf_owned) =
6390 DataTableState::scan_parquet_hive_with_schema(p, merged_schema.clone())
6391 {
6392 match DataTableState::from_schema_and_lazyframe(
6393 merged_schema,
6394 lf_owned,
6395 options,
6396 Some(partition_columns),
6397 ) {
6398 Ok(state) => {
6399 self.debug.schema_load = Some("one-file (local)".to_string());
6400 self.parquet_metadata_cache = None;
6401 self.export_df = None;
6402 self.data_table_state = Some(state);
6403 self.path = path.clone();
6404 if let Some(ref path_p) = path {
6405 self.original_file_format = path_p
6406 .extension()
6407 .and_then(|e| e.to_str())
6408 .and_then(|ext| {
6409 if ext.eq_ignore_ascii_case("parquet") {
6410 Some(ExportFormat::Parquet)
6411 } else if ext.eq_ignore_ascii_case("csv") {
6412 Some(ExportFormat::Csv)
6413 } else if ext.eq_ignore_ascii_case("json") {
6414 Some(ExportFormat::Json)
6415 } else if ext.eq_ignore_ascii_case("jsonl")
6416 || ext.eq_ignore_ascii_case("ndjson")
6417 {
6418 Some(ExportFormat::Ndjson)
6419 } else if ext.eq_ignore_ascii_case("arrow")
6420 || ext.eq_ignore_ascii_case("ipc")
6421 || ext.eq_ignore_ascii_case("feather")
6422 {
6423 Some(ExportFormat::Ipc)
6424 } else if ext.eq_ignore_ascii_case("avro") {
6425 Some(ExportFormat::Avro)
6426 } else {
6427 None
6428 }
6429 });
6430 self.original_file_delimiter =
6431 Some(options.delimiter.unwrap_or(b','));
6432 } else {
6433 self.original_file_format = None;
6434 self.original_file_delimiter = None;
6435 }
6436 self.sort_filter_modal = SortFilterModal::new();
6437 self.pivot_melt_modal = PivotMeltModal::new();
6438 if let LoadingState::Loading {
6439 file_path,
6440 file_size,
6441 ..
6442 } = &self.loading_state
6443 {
6444 self.loading_state = LoadingState::Loading {
6445 file_path: file_path.clone(),
6446 file_size: *file_size,
6447 current_phase: "Loading buffer".to_string(),
6448 progress_percent: 70,
6449 };
6450 }
6451 return Some(AppEvent::DoLoadBuffer);
6452 }
6453 Err(e) => {
6454 self.loading_state = LoadingState::Idle;
6455 self.busy = false;
6456 self.drain_keys_on_next_loop = true;
6457 let msg =
6458 crate::error_display::user_message_from_report(&e, None);
6459 return Some(AppEvent::Crash(msg));
6460 }
6461 }
6462 }
6463 }
6464 }
6465
6466 #[cfg(feature = "cloud")]
6467 {
6468 if options.single_spine_schema
6471 && path.as_ref().is_some_and(|p| {
6472 let s = p.as_os_str().to_string_lossy();
6473 let is_cloud = s.starts_with("s3://") || s.starts_with("gs://");
6474 let looks_like_hive = s.ends_with('/') || s.contains('*');
6475 is_cloud && (options.hive || looks_like_hive)
6476 })
6477 {
6478 self.debug.schema_load = Some("trying one-file (cloud)".to_string());
6479 let src = source::input_source(path.as_ref().expect("path set by caller"));
6480 let try_cloud = match &src {
6481 source::InputSource::S3(url) => {
6482 let full = format!("s3://{url}");
6483 let (path_part, _) = source::url_path_extension(&full);
6484 let key = path_part
6485 .split_once('/')
6486 .map(|(_, k)| k.trim_end_matches('/'))
6487 .unwrap_or("");
6488 let cloud_opts =
6489 Self::build_s3_cloud_options(&self.app_config.cloud, options);
6490 Self::build_s3_object_store(&full, &self.app_config.cloud, options)
6491 .ok()
6492 .and_then(|store| {
6493 let rt = tokio::runtime::Runtime::new().ok()?;
6494 let (merged_schema, partition_columns) = rt
6495 .block_on(cloud_hive::schema_from_one_cloud_hive(
6496 store, key,
6497 ))
6498 .ok()?;
6499 let pl_path = PlPathRef::new(&full).into_owned();
6500 let args = ScanArgsParquet {
6501 schema: Some(merged_schema.clone()),
6502 cloud_options: Some(cloud_opts),
6503 hive_options: polars::io::HiveOptions::new_enabled(),
6504 glob: true,
6505 ..Default::default()
6506 };
6507 let mut lf_owned =
6508 LazyFrame::scan_parquet(pl_path, args).ok()?;
6509 if !partition_columns.is_empty() {
6510 let exprs: Vec<_> = partition_columns
6511 .iter()
6512 .map(|s| col(s.as_str()))
6513 .chain(
6514 merged_schema
6515 .iter_names()
6516 .map(|s| s.to_string())
6517 .filter(|c| !partition_columns.contains(c))
6518 .map(|s| col(s.as_str())),
6519 )
6520 .collect();
6521 lf_owned = lf_owned.select(exprs);
6522 }
6523 DataTableState::from_schema_and_lazyframe(
6524 merged_schema,
6525 lf_owned,
6526 options,
6527 Some(partition_columns),
6528 )
6529 .ok()
6530 })
6531 }
6532 source::InputSource::Gcs(url) => {
6533 let full = format!("gs://{url}");
6534 let (path_part, _) = source::url_path_extension(&full);
6535 let key = path_part
6536 .split_once('/')
6537 .map(|(_, k)| k.trim_end_matches('/'))
6538 .unwrap_or("");
6539 Self::build_gcs_object_store(&full).ok().and_then(|store| {
6540 let rt = tokio::runtime::Runtime::new().ok()?;
6541 let (merged_schema, partition_columns) = rt
6542 .block_on(cloud_hive::schema_from_one_cloud_hive(
6543 store, key,
6544 ))
6545 .ok()?;
6546 let pl_path = PlPathRef::new(&full).into_owned();
6547 let args = ScanArgsParquet {
6548 schema: Some(merged_schema.clone()),
6549 cloud_options: Some(CloudOptions::default()),
6550 hive_options: polars::io::HiveOptions::new_enabled(),
6551 glob: true,
6552 ..Default::default()
6553 };
6554 let mut lf_owned =
6555 LazyFrame::scan_parquet(pl_path, args).ok()?;
6556 if !partition_columns.is_empty() {
6557 let exprs: Vec<_> = partition_columns
6558 .iter()
6559 .map(|s| col(s.as_str()))
6560 .chain(
6561 merged_schema
6562 .iter_names()
6563 .map(|s| s.to_string())
6564 .filter(|c| !partition_columns.contains(c))
6565 .map(|s| col(s.as_str())),
6566 )
6567 .collect();
6568 lf_owned = lf_owned.select(exprs);
6569 }
6570 DataTableState::from_schema_and_lazyframe(
6571 merged_schema,
6572 lf_owned,
6573 options,
6574 Some(partition_columns),
6575 )
6576 .ok()
6577 })
6578 }
6579 _ => None,
6580 };
6581 if let Some(state) = try_cloud {
6582 self.debug.schema_load = Some("one-file (cloud)".to_string());
6583 self.parquet_metadata_cache = None;
6584 self.export_df = None;
6585 self.data_table_state = Some(state);
6586 self.path = path.clone();
6587 if let Some(ref path_p) = path {
6588 self.original_file_format =
6589 path_p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6590 if ext.eq_ignore_ascii_case("parquet") {
6591 Some(ExportFormat::Parquet)
6592 } else if ext.eq_ignore_ascii_case("csv") {
6593 Some(ExportFormat::Csv)
6594 } else if ext.eq_ignore_ascii_case("json") {
6595 Some(ExportFormat::Json)
6596 } else if ext.eq_ignore_ascii_case("jsonl")
6597 || ext.eq_ignore_ascii_case("ndjson")
6598 {
6599 Some(ExportFormat::Ndjson)
6600 } else if ext.eq_ignore_ascii_case("arrow")
6601 || ext.eq_ignore_ascii_case("ipc")
6602 || ext.eq_ignore_ascii_case("feather")
6603 {
6604 Some(ExportFormat::Ipc)
6605 } else if ext.eq_ignore_ascii_case("avro") {
6606 Some(ExportFormat::Avro)
6607 } else {
6608 None
6609 }
6610 });
6611 self.original_file_delimiter =
6612 Some(options.delimiter.unwrap_or(b','));
6613 } else {
6614 self.original_file_format = None;
6615 self.original_file_delimiter = None;
6616 }
6617 self.sort_filter_modal = SortFilterModal::new();
6618 self.pivot_melt_modal = PivotMeltModal::new();
6619 if let LoadingState::Loading {
6620 file_path,
6621 file_size,
6622 ..
6623 } = &self.loading_state
6624 {
6625 self.loading_state = LoadingState::Loading {
6626 file_path: file_path.clone(),
6627 file_size: *file_size,
6628 current_phase: "Loading buffer".to_string(),
6629 progress_percent: 70,
6630 };
6631 }
6632 return Some(AppEvent::DoLoadBuffer);
6633 } else {
6634 self.debug.schema_load = Some("fallback (cloud)".to_string());
6635 }
6636 }
6637 }
6638
6639 if self.debug.schema_load.is_none() {
6640 self.debug.schema_load = Some("full scan".to_string());
6641 }
6642 let mut lf_owned = (**lf).clone();
6643 match lf_owned.collect_schema() {
6644 Ok(schema) => {
6645 let partition_columns = if path.as_ref().is_some_and(|p| {
6646 options.hive
6647 && (p.is_dir() || p.as_os_str().to_string_lossy().contains('*'))
6648 }) {
6649 let discovered = DataTableState::discover_hive_partition_columns(
6650 path.as_ref().expect("path set by caller"),
6651 );
6652 discovered
6653 .into_iter()
6654 .filter(|c| schema.contains(c.as_str()))
6655 .collect::<Vec<_>>()
6656 } else {
6657 Vec::new()
6658 };
6659 if !partition_columns.is_empty() {
6660 let exprs: Vec<_> = partition_columns
6661 .iter()
6662 .map(|s| col(s.as_str()))
6663 .chain(
6664 schema
6665 .iter_names()
6666 .map(|s| s.to_string())
6667 .filter(|c| !partition_columns.contains(c))
6668 .map(|s| col(s.as_str())),
6669 )
6670 .collect();
6671 lf_owned = lf_owned.select(exprs);
6672 }
6673 let part_cols_opt = if partition_columns.is_empty() {
6674 None
6675 } else {
6676 Some(partition_columns)
6677 };
6678 match DataTableState::from_schema_and_lazyframe(
6679 schema,
6680 lf_owned,
6681 options,
6682 part_cols_opt,
6683 ) {
6684 Ok(state) => {
6685 self.parquet_metadata_cache = None;
6686 self.export_df = None;
6687 self.data_table_state = Some(state);
6688 self.path = path.clone();
6689 if let Some(ref p) = path {
6690 self.original_file_format =
6691 p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6692 if ext.eq_ignore_ascii_case("parquet") {
6693 Some(ExportFormat::Parquet)
6694 } else if ext.eq_ignore_ascii_case("csv") {
6695 Some(ExportFormat::Csv)
6696 } else if ext.eq_ignore_ascii_case("json") {
6697 Some(ExportFormat::Json)
6698 } else if ext.eq_ignore_ascii_case("jsonl")
6699 || ext.eq_ignore_ascii_case("ndjson")
6700 {
6701 Some(ExportFormat::Ndjson)
6702 } else if ext.eq_ignore_ascii_case("arrow")
6703 || ext.eq_ignore_ascii_case("ipc")
6704 || ext.eq_ignore_ascii_case("feather")
6705 {
6706 Some(ExportFormat::Ipc)
6707 } else if ext.eq_ignore_ascii_case("avro") {
6708 Some(ExportFormat::Avro)
6709 } else {
6710 None
6711 }
6712 });
6713 self.original_file_delimiter =
6714 Some(options.delimiter.unwrap_or(b','));
6715 } else {
6716 self.original_file_format = None;
6717 self.original_file_delimiter = None;
6718 }
6719 self.sort_filter_modal = SortFilterModal::new();
6720 self.pivot_melt_modal = PivotMeltModal::new();
6721 if let LoadingState::Loading {
6722 file_path,
6723 file_size,
6724 ..
6725 } = &self.loading_state
6726 {
6727 self.loading_state = LoadingState::Loading {
6728 file_path: file_path.clone(),
6729 file_size: *file_size,
6730 current_phase: "Loading buffer".to_string(),
6731 progress_percent: 70,
6732 };
6733 }
6734 Some(AppEvent::DoLoadBuffer)
6735 }
6736 Err(e) => {
6737 self.loading_state = LoadingState::Idle;
6738 self.busy = false;
6739 self.drain_keys_on_next_loop = true;
6740 let msg = crate::error_display::user_message_from_report(&e, None);
6741 Some(AppEvent::Crash(msg))
6742 }
6743 }
6744 }
6745 Err(e) => {
6746 self.loading_state = LoadingState::Idle;
6747 self.busy = false;
6748 self.drain_keys_on_next_loop = true;
6749 let report = color_eyre::eyre::Report::from(e);
6750 let msg = crate::error_display::user_message_from_report(&report, None);
6751 Some(AppEvent::Crash(msg))
6752 }
6753 }
6754 }
6755 AppEvent::DoLoadBuffer => {
6756 if let Some(state) = &mut self.data_table_state {
6757 state.collect();
6758 if let Some(e) = state.error.take() {
6759 self.loading_state = LoadingState::Idle;
6760 self.busy = false;
6761 self.drain_keys_on_next_loop = true;
6762 let msg = crate::error_display::user_message_from_polars(&e);
6763 return Some(AppEvent::Crash(msg));
6764 }
6765 }
6766 self.loading_state = LoadingState::Idle;
6767 self.busy = false;
6768 self.drain_keys_on_next_loop = true;
6769 Some(AppEvent::Collect)
6770 }
6771 AppEvent::DoLoad(paths, options) => {
6772 let first = &paths[0];
6773 let compression = options
6775 .compression
6776 .or_else(|| CompressionFormat::from_extension(first));
6777 let is_csv = first
6778 .file_stem()
6779 .and_then(|stem| stem.to_str())
6780 .map(|stem| {
6781 stem.ends_with(".csv")
6782 || first
6783 .extension()
6784 .and_then(|e| e.to_str())
6785 .map(|e| e.eq_ignore_ascii_case("csv"))
6786 .unwrap_or(false)
6787 })
6788 .unwrap_or(false);
6789 let is_compressed_csv = paths.len() == 1 && compression.is_some() && is_csv;
6790
6791 if is_compressed_csv {
6792 if let LoadingState::Loading {
6794 file_path,
6795 file_size,
6796 ..
6797 } = &self.loading_state
6798 {
6799 self.loading_state = LoadingState::Loading {
6800 file_path: file_path.clone(),
6801 file_size: *file_size,
6802 current_phase: "Decompressing".to_string(),
6803 progress_percent: 30,
6804 };
6805 }
6806 Some(AppEvent::DoDecompress(paths.clone(), options.clone()))
6808 } else {
6809 match self.load(paths, options) {
6811 Ok(_) => {
6812 self.busy = false;
6813 self.drain_keys_on_next_loop = true;
6814 Some(AppEvent::Collect)
6815 }
6816 Err(e) => {
6817 self.loading_state = LoadingState::Idle;
6818 self.busy = false;
6819 self.drain_keys_on_next_loop = true;
6820 let msg = crate::error_display::user_message_from_report(
6821 &e,
6822 paths.first().map(|p| p.as_path()),
6823 );
6824 Some(AppEvent::Crash(msg))
6825 }
6826 }
6827 }
6828 }
6829 AppEvent::DoDecompress(paths, options) => {
6830 match self.load(paths, options) {
6832 Ok(_) => Some(AppEvent::DoLoadBuffer),
6833 Err(e) => {
6834 self.loading_state = LoadingState::Idle;
6835 self.busy = false;
6836 self.drain_keys_on_next_loop = true;
6837 let msg = crate::error_display::user_message_from_report(
6838 &e,
6839 paths.first().map(|p| p.as_path()),
6840 );
6841 Some(AppEvent::Crash(msg))
6842 }
6843 }
6844 }
6845 AppEvent::Resize(_cols, rows) => {
6846 self.busy = true;
6847 if let Some(state) = &mut self.data_table_state {
6848 state.visible_rows = *rows as usize;
6849 state.collect();
6850 }
6851 self.busy = false;
6852 self.drain_keys_on_next_loop = true;
6853 None
6854 }
6855 AppEvent::Collect => {
6856 self.busy = true;
6857 if let Some(ref mut state) = self.data_table_state {
6858 state.collect();
6859 }
6860 self.busy = false;
6861 self.drain_keys_on_next_loop = true;
6862 None
6863 }
6864 AppEvent::DoScrollDown => {
6865 if let Some(state) = &mut self.data_table_state {
6866 state.page_down();
6867 }
6868 self.busy = false;
6869 self.drain_keys_on_next_loop = true;
6870 None
6871 }
6872 AppEvent::DoScrollUp => {
6873 if let Some(state) = &mut self.data_table_state {
6874 state.page_up();
6875 }
6876 self.busy = false;
6877 self.drain_keys_on_next_loop = true;
6878 None
6879 }
6880 AppEvent::DoScrollNext => {
6881 if let Some(state) = &mut self.data_table_state {
6882 state.select_next();
6883 }
6884 self.busy = false;
6885 self.drain_keys_on_next_loop = true;
6886 None
6887 }
6888 AppEvent::DoScrollPrev => {
6889 if let Some(state) = &mut self.data_table_state {
6890 state.select_previous();
6891 }
6892 self.busy = false;
6893 self.drain_keys_on_next_loop = true;
6894 None
6895 }
6896 AppEvent::DoScrollEnd => {
6897 if let Some(state) = &mut self.data_table_state {
6898 state.scroll_to_end();
6899 }
6900 self.busy = false;
6901 self.drain_keys_on_next_loop = true;
6902 None
6903 }
6904 AppEvent::DoScrollHalfDown => {
6905 if let Some(state) = &mut self.data_table_state {
6906 state.half_page_down();
6907 }
6908 self.busy = false;
6909 self.drain_keys_on_next_loop = true;
6910 None
6911 }
6912 AppEvent::DoScrollHalfUp => {
6913 if let Some(state) = &mut self.data_table_state {
6914 state.half_page_up();
6915 }
6916 self.busy = false;
6917 self.drain_keys_on_next_loop = true;
6918 None
6919 }
6920 AppEvent::GoToLine(n) => {
6921 if let Some(state) = &mut self.data_table_state {
6922 state.scroll_to_row_centered(*n);
6923 }
6924 self.busy = false;
6925 self.drain_keys_on_next_loop = true;
6926 None
6927 }
6928 AppEvent::AnalysisChunk => {
6929 let lf = match &self.data_table_state {
6930 Some(state) => state.lf.clone(),
6931 None => {
6932 self.analysis_computation = None;
6933 self.analysis_modal.computing = None;
6934 self.busy = false;
6935 return None;
6936 }
6937 };
6938 let comp = self.analysis_computation.take()?;
6939 if comp.df.is_none() {
6940 let total_rows = match self
6943 .data_table_state
6944 .as_ref()
6945 .and_then(|s| s.num_rows_if_valid())
6946 {
6947 Some(n) => n,
6948 None => match crate::statistics::collect_lazy(
6949 lf.clone().select([len()]),
6950 self.app_config.performance.polars_streaming,
6951 ) {
6952 Ok(count_df) => {
6953 if let Some(col) = count_df.get(0) {
6954 match col.first() {
6955 Some(AnyValue::UInt32(n)) => *n as usize,
6956 _ => 0,
6957 }
6958 } else {
6959 0
6960 }
6961 }
6962 Err(_e) => {
6963 self.analysis_modal.computing = None;
6964 self.busy = false;
6965 self.drain_keys_on_next_loop = true;
6966 return None;
6967 }
6968 },
6969 };
6970 match crate::statistics::compute_describe_from_lazy(
6971 &lf,
6972 total_rows,
6973 self.sampling_threshold,
6974 comp.sample_seed,
6975 self.app_config.performance.polars_streaming,
6976 ) {
6977 Ok(results) => {
6978 self.analysis_modal.describe_results = Some(results);
6979 self.analysis_modal.computing = None;
6980 self.busy = false;
6981 self.drain_keys_on_next_loop = true;
6982 None
6983 }
6984 Err(_e) => {
6985 self.analysis_modal.computing = None;
6986 self.busy = false;
6987 self.drain_keys_on_next_loop = true;
6988 None
6989 }
6990 }
6991 } else {
6992 None
6993 }
6994 }
6995 AppEvent::AnalysisDistributionCompute => {
6996 if let Some(state) = &self.data_table_state {
6997 let options = crate::statistics::ComputeOptions {
6998 include_distribution_info: true,
6999 include_distribution_analyses: true,
7000 include_correlation_matrix: false,
7001 include_skewness_kurtosis_outliers: true,
7002 polars_streaming: self.app_config.performance.polars_streaming,
7003 };
7004 if let Ok(results) = crate::statistics::compute_statistics_with_options(
7005 &state.lf,
7006 self.sampling_threshold,
7007 self.analysis_modal.random_seed,
7008 options,
7009 ) {
7010 self.analysis_modal.distribution_results = Some(results);
7011 }
7012 }
7013 self.analysis_modal.computing = None;
7014 self.busy = false;
7015 self.drain_keys_on_next_loop = true;
7016 None
7017 }
7018 AppEvent::AnalysisCorrelationCompute => {
7019 if let Some(state) = &self.data_table_state {
7020 if let Ok(df) =
7021 crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
7022 {
7023 if let Ok(matrix) = crate::statistics::compute_correlation_matrix(&df) {
7024 self.analysis_modal.correlation_results =
7025 Some(crate::statistics::AnalysisResults {
7026 column_statistics: vec![],
7027 total_rows: df.height(),
7028 sample_size: None,
7029 sample_seed: self.analysis_modal.random_seed,
7030 correlation_matrix: Some(matrix),
7031 distribution_analyses: vec![],
7032 });
7033 }
7034 }
7035 }
7036 self.analysis_modal.computing = None;
7037 self.busy = false;
7038 self.drain_keys_on_next_loop = true;
7039 None
7040 }
7041 AppEvent::Search(query) => {
7042 let query_succeeded = if let Some(state) = &mut self.data_table_state {
7043 state.query(query.clone());
7044 state.error.is_none()
7045 } else {
7046 false
7047 };
7048
7049 if query_succeeded {
7051 self.input_mode = InputMode::Normal;
7053 self.query_input.set_focused(false);
7054 if let Some(state) = &mut self.data_table_state {
7056 state.suppress_error_display = false;
7057 }
7058 }
7059 None
7062 }
7063 AppEvent::SqlSearch(sql) => {
7064 let sql_succeeded = if let Some(state) = &mut self.data_table_state {
7065 state.sql_query(sql.clone());
7066 state.error.is_none()
7067 } else {
7068 false
7069 };
7070 if sql_succeeded {
7071 self.input_mode = InputMode::Normal;
7072 self.sql_input.set_focused(false);
7073 if let Some(state) = &mut self.data_table_state {
7074 state.suppress_error_display = false;
7075 }
7076 Some(AppEvent::Collect)
7077 } else {
7078 None
7079 }
7080 }
7081 AppEvent::FuzzySearch(query) => {
7082 let fuzzy_succeeded = if let Some(state) = &mut self.data_table_state {
7083 state.fuzzy_search(query.clone());
7084 state.error.is_none()
7085 } else {
7086 false
7087 };
7088 if fuzzy_succeeded {
7089 self.input_mode = InputMode::Normal;
7090 self.fuzzy_input.set_focused(false);
7091 if let Some(state) = &mut self.data_table_state {
7092 state.suppress_error_display = false;
7093 }
7094 Some(AppEvent::Collect)
7095 } else {
7096 None
7097 }
7098 }
7099 AppEvent::Filter(statements) => {
7100 if let Some(state) = &mut self.data_table_state {
7101 state.filter(statements.clone());
7102 }
7103 None
7104 }
7105 AppEvent::Sort(columns, ascending) => {
7106 if let Some(state) = &mut self.data_table_state {
7107 state.sort(columns.clone(), *ascending);
7108 }
7109 None
7110 }
7111 AppEvent::Reset => {
7112 if let Some(state) = &mut self.data_table_state {
7113 state.reset();
7114 }
7115 self.active_template_id = None;
7117 None
7118 }
7119 AppEvent::ColumnOrder(order, locked_count) => {
7120 if let Some(state) = &mut self.data_table_state {
7121 state.set_column_order(order.clone());
7122 state.set_locked_columns(*locked_count);
7123 }
7124 None
7125 }
7126 AppEvent::Pivot(spec) => {
7127 self.busy = true;
7128 if let Some(state) = &mut self.data_table_state {
7129 match state.pivot(spec) {
7130 Ok(()) => {
7131 self.pivot_melt_modal.close();
7132 self.input_mode = InputMode::Normal;
7133 Some(AppEvent::Collect)
7134 }
7135 Err(e) => {
7136 self.busy = false;
7137 self.error_modal
7138 .show(crate::error_display::user_message_from_report(&e, None));
7139 None
7140 }
7141 }
7142 } else {
7143 self.busy = false;
7144 None
7145 }
7146 }
7147 AppEvent::Melt(spec) => {
7148 self.busy = true;
7149 if let Some(state) = &mut self.data_table_state {
7150 match state.melt(spec) {
7151 Ok(()) => {
7152 self.pivot_melt_modal.close();
7153 self.input_mode = InputMode::Normal;
7154 Some(AppEvent::Collect)
7155 }
7156 Err(e) => {
7157 self.busy = false;
7158 self.error_modal
7159 .show(crate::error_display::user_message_from_report(&e, None));
7160 None
7161 }
7162 }
7163 } else {
7164 self.busy = false;
7165 None
7166 }
7167 }
7168 AppEvent::ChartExport(path, format, title) => {
7169 self.busy = true;
7170 self.loading_state = LoadingState::Exporting {
7171 file_path: path.clone(),
7172 current_phase: "Exporting chart".to_string(),
7173 progress_percent: 0,
7174 };
7175 Some(AppEvent::DoChartExport(
7176 path.clone(),
7177 *format,
7178 title.clone(),
7179 ))
7180 }
7181 AppEvent::DoChartExport(path, format, title) => {
7182 let result = self.do_chart_export(path, *format, title);
7183 self.loading_state = LoadingState::Idle;
7184 self.busy = false;
7185 self.drain_keys_on_next_loop = true;
7186 match result {
7187 Ok(()) => {
7188 self.success_modal.show(format!(
7189 "Chart exported successfully to\n{}",
7190 path.display()
7191 ));
7192 self.chart_export_modal.close();
7193 }
7194 Err(e) => {
7195 self.error_modal
7196 .show(crate::error_display::user_message_from_report(
7197 &e,
7198 Some(path),
7199 ));
7200 self.chart_export_modal.reopen_with_path(path, *format);
7201 }
7202 }
7203 None
7204 }
7205 AppEvent::Export(path, format, options) => {
7206 if let Some(_state) = &self.data_table_state {
7207 self.busy = true;
7208 self.loading_state = LoadingState::Exporting {
7210 file_path: path.clone(),
7211 current_phase: "Preparing export".to_string(),
7212 progress_percent: 0,
7213 };
7214 Some(AppEvent::DoExport(path.clone(), *format, options.clone()))
7216 } else {
7217 None
7218 }
7219 }
7220 AppEvent::DoExport(path, format, options) => {
7221 if let Some(_state) = &self.data_table_state {
7222 self.loading_state = LoadingState::Exporting {
7224 file_path: path.clone(),
7225 current_phase: "Collecting data".to_string(),
7226 progress_percent: 10,
7227 };
7228 Some(AppEvent::DoExportCollect(
7229 path.clone(),
7230 *format,
7231 options.clone(),
7232 ))
7233 } else {
7234 self.busy = false;
7235 None
7236 }
7237 }
7238 AppEvent::DoExportCollect(path, format, options) => {
7239 if let Some(state) = &self.data_table_state {
7240 match crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
7241 {
7242 Ok(df) => {
7243 self.export_df = Some(df);
7244 let has_compression = match format {
7245 ExportFormat::Csv => options.csv_compression.is_some(),
7246 ExportFormat::Json => options.json_compression.is_some(),
7247 ExportFormat::Ndjson => options.ndjson_compression.is_some(),
7248 ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => {
7249 false
7250 }
7251 };
7252 let phase = if has_compression {
7253 "Writing and compressing file"
7254 } else {
7255 "Writing file"
7256 };
7257 self.loading_state = LoadingState::Exporting {
7258 file_path: path.clone(),
7259 current_phase: phase.to_string(),
7260 progress_percent: 50,
7261 };
7262 Some(AppEvent::DoExportWrite(
7263 path.clone(),
7264 *format,
7265 options.clone(),
7266 ))
7267 }
7268 Err(e) => {
7269 self.loading_state = LoadingState::Idle;
7270 self.busy = false;
7271 self.drain_keys_on_next_loop = true;
7272 self.error_modal.show(format!(
7273 "Export failed: {}",
7274 crate::error_display::user_message_from_polars(&e)
7275 ));
7276 None
7277 }
7278 }
7279 } else {
7280 self.busy = false;
7281 None
7282 }
7283 }
7284 AppEvent::DoExportWrite(path, format, options) => {
7285 let result = self
7286 .export_df
7287 .take()
7288 .map(|mut df| Self::export_data_from_df(&mut df, path, *format, options));
7289 self.loading_state = LoadingState::Idle;
7290 self.busy = false;
7291 self.drain_keys_on_next_loop = true;
7292 match result {
7293 Some(Ok(())) => {
7294 self.success_modal
7295 .show(format!("Data exported successfully to\n{}", path.display()));
7296 }
7297 Some(Err(e)) => {
7298 let error_msg = Self::format_export_error(&e, path);
7299 self.error_modal.show(error_msg);
7300 }
7301 None => {}
7302 }
7303 None
7304 }
7305 AppEvent::DoLoadParquetMetadata => {
7306 let path = self.path.clone();
7307 if let Some(p) = &path {
7308 if let Some(meta) = read_parquet_metadata(p) {
7309 self.parquet_metadata_cache = Some(meta);
7310 }
7311 }
7312 self.busy = false;
7313 self.drain_keys_on_next_loop = true;
7314 None
7315 }
7316 _ => None,
7317 }
7318 }
7319
7320 fn do_chart_export(
7323 &self,
7324 path: &Path,
7325 format: ChartExportFormat,
7326 title: &str,
7327 ) -> color_eyre::Result<()> {
7328 let state = self
7329 .data_table_state
7330 .as_ref()
7331 .ok_or_else(|| color_eyre::eyre::eyre!("No data loaded"))?;
7332 let chart_title = title.trim();
7333 let chart_title = if chart_title.is_empty() {
7334 None
7335 } else {
7336 Some(chart_title.to_string())
7337 };
7338
7339 match self.chart_modal.chart_kind {
7340 ChartKind::XY => {
7341 let x_column = self
7342 .chart_modal
7343 .effective_x_column()
7344 .ok_or_else(|| color_eyre::eyre::eyre!("No X axis column selected"))?;
7345 let y_columns = self.chart_modal.effective_y_columns();
7346 if y_columns.is_empty() {
7347 return Err(color_eyre::eyre::eyre!("No Y axis columns selected"));
7348 }
7349
7350 let row_limit_opt = self.chart_modal.row_limit;
7351 let row_limit = self.chart_modal.effective_row_limit();
7352 let cache_matches = self.chart_cache.xy.as_ref().is_some_and(|c| {
7353 c.x_column == *x_column
7354 && c.y_columns == y_columns
7355 && c.row_limit == row_limit_opt
7356 });
7357
7358 let (series_vec, x_axis_kind_export, from_cache) = if cache_matches {
7359 if let Some(cache) = self.chart_cache.xy.as_ref() {
7360 let pts = if self.chart_modal.log_scale {
7361 cache.series_log.as_ref().cloned().unwrap_or_else(|| {
7362 cache
7363 .series
7364 .iter()
7365 .map(|s| {
7366 s.iter().map(|&(x, y)| (x, y.max(0.0).ln_1p())).collect()
7367 })
7368 .collect()
7369 })
7370 } else {
7371 cache.series.clone()
7372 };
7373 (pts, cache.x_axis_kind, true)
7374 } else {
7375 let r = chart_data::prepare_chart_data(
7376 &state.lf,
7377 &state.schema,
7378 x_column,
7379 &y_columns,
7380 row_limit,
7381 )?;
7382 (r.series, r.x_axis_kind, false)
7383 }
7384 } else {
7385 let r = chart_data::prepare_chart_data(
7386 &state.lf,
7387 &state.schema,
7388 x_column,
7389 &y_columns,
7390 row_limit,
7391 )?;
7392 (r.series, r.x_axis_kind, false)
7393 };
7394
7395 let log_scale = self.chart_modal.log_scale;
7396 let series: Vec<ChartExportSeries> = series_vec
7397 .iter()
7398 .zip(y_columns.iter())
7399 .filter(|(points, _)| !points.is_empty())
7400 .map(|(points, name)| {
7401 let pts = if log_scale && !from_cache {
7402 points
7403 .iter()
7404 .map(|&(x, y)| (x, y.max(0.0).ln_1p()))
7405 .collect()
7406 } else {
7407 points.clone()
7408 };
7409 ChartExportSeries {
7410 name: name.clone(),
7411 points: pts,
7412 }
7413 })
7414 .collect();
7415
7416 if series.is_empty() {
7417 return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7418 }
7419
7420 let mut all_x_min = f64::INFINITY;
7421 let mut all_x_max = f64::NEG_INFINITY;
7422 let mut all_y_min = f64::INFINITY;
7423 let mut all_y_max = f64::NEG_INFINITY;
7424 for s in &series {
7425 for &(x, y) in &s.points {
7426 all_x_min = all_x_min.min(x);
7427 all_x_max = all_x_max.max(x);
7428 all_y_min = all_y_min.min(y);
7429 all_y_max = all_y_max.max(y);
7430 }
7431 }
7432
7433 let chart_type = self.chart_modal.chart_type;
7434 let y_starts_at_zero = self.chart_modal.y_starts_at_zero;
7435 let y_min_bounds = if chart_type == ChartType::Bar {
7436 0.0_f64.min(all_y_min)
7437 } else if y_starts_at_zero {
7438 0.0
7439 } else {
7440 all_y_min
7441 };
7442 let y_max_bounds = if all_y_max > y_min_bounds {
7443 all_y_max
7444 } else {
7445 y_min_bounds + 1.0
7446 };
7447 let x_min_bounds = if all_x_max > all_x_min {
7448 all_x_min
7449 } else {
7450 all_x_min - 0.5
7451 };
7452 let x_max_bounds = if all_x_max > all_x_min {
7453 all_x_max
7454 } else {
7455 all_x_min + 0.5
7456 };
7457
7458 let x_label = x_column.to_string();
7459 let y_label = y_columns.join(", ");
7460 let bounds = ChartExportBounds {
7461 x_min: x_min_bounds,
7462 x_max: x_max_bounds,
7463 y_min: y_min_bounds,
7464 y_max: y_max_bounds,
7465 x_label: x_label.clone(),
7466 y_label: y_label.clone(),
7467 x_axis_kind: x_axis_kind_export,
7468 log_scale: self.chart_modal.log_scale,
7469 chart_title,
7470 };
7471
7472 match format {
7473 ChartExportFormat::Png => write_chart_png(path, &series, chart_type, &bounds),
7474 ChartExportFormat::Eps => write_chart_eps(path, &series, chart_type, &bounds),
7475 }
7476 }
7477 ChartKind::Histogram => {
7478 let column = self
7479 .chart_modal
7480 .effective_hist_column()
7481 .ok_or_else(|| color_eyre::eyre::eyre!("No histogram column selected"))?;
7482 let row_limit = self.chart_modal.effective_row_limit();
7483 let data = if let Some(c) = self.chart_cache.histogram.as_ref().filter(|c| {
7484 c.column == column
7485 && c.bins == self.chart_modal.hist_bins
7486 && c.row_limit == self.chart_modal.row_limit
7487 }) {
7488 c.data.clone()
7489 } else {
7490 chart_data::prepare_histogram_data(
7491 &state.lf,
7492 &column,
7493 self.chart_modal.hist_bins,
7494 row_limit,
7495 )?
7496 };
7497 if data.bins.is_empty() {
7498 return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7499 }
7500 let points: Vec<(f64, f64)> =
7501 data.bins.iter().map(|b| (b.center, b.count)).collect();
7502 let series = vec![ChartExportSeries {
7503 name: column.clone(),
7504 points,
7505 }];
7506 let x_max = if data.x_max > data.x_min {
7507 data.x_max
7508 } else {
7509 data.x_min + 1.0
7510 };
7511 let y_max = if data.max_count > 0.0 {
7512 data.max_count
7513 } else {
7514 1.0
7515 };
7516 let bounds = ChartExportBounds {
7517 x_min: data.x_min,
7518 x_max,
7519 y_min: 0.0,
7520 y_max,
7521 x_label: column.clone(),
7522 y_label: "Count".to_string(),
7523 x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7524 log_scale: false,
7525 chart_title,
7526 };
7527 match format {
7528 ChartExportFormat::Png => {
7529 write_chart_png(path, &series, ChartType::Bar, &bounds)
7530 }
7531 ChartExportFormat::Eps => {
7532 write_chart_eps(path, &series, ChartType::Bar, &bounds)
7533 }
7534 }
7535 }
7536 ChartKind::BoxPlot => {
7537 let column = self
7538 .chart_modal
7539 .effective_box_column()
7540 .ok_or_else(|| color_eyre::eyre::eyre!("No box plot column selected"))?;
7541 let row_limit = self.chart_modal.effective_row_limit();
7542 let data = if let Some(c) = self
7543 .chart_cache
7544 .box_plot
7545 .as_ref()
7546 .filter(|c| c.column == column && c.row_limit == self.chart_modal.row_limit)
7547 {
7548 c.data.clone()
7549 } else {
7550 chart_data::prepare_box_plot_data(
7551 &state.lf,
7552 std::slice::from_ref(&column),
7553 row_limit,
7554 )?
7555 };
7556 if data.stats.is_empty() {
7557 return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7558 }
7559 let bounds = BoxPlotExportBounds {
7560 y_min: data.y_min,
7561 y_max: data.y_max,
7562 x_labels: vec![column.clone()],
7563 x_label: "Columns".to_string(),
7564 y_label: "Value".to_string(),
7565 chart_title,
7566 };
7567 match format {
7568 ChartExportFormat::Png => write_box_plot_png(path, &data, &bounds),
7569 ChartExportFormat::Eps => write_box_plot_eps(path, &data, &bounds),
7570 }
7571 }
7572 ChartKind::Kde => {
7573 let column = self
7574 .chart_modal
7575 .effective_kde_column()
7576 .ok_or_else(|| color_eyre::eyre::eyre!("No KDE column selected"))?;
7577 let row_limit = self.chart_modal.effective_row_limit();
7578 let data = if let Some(c) = self.chart_cache.kde.as_ref().filter(|c| {
7579 c.column == column
7580 && c.bandwidth_factor == self.chart_modal.kde_bandwidth_factor
7581 && c.row_limit == self.chart_modal.row_limit
7582 }) {
7583 c.data.clone()
7584 } else {
7585 chart_data::prepare_kde_data(
7586 &state.lf,
7587 std::slice::from_ref(&column),
7588 self.chart_modal.kde_bandwidth_factor,
7589 row_limit,
7590 )?
7591 };
7592 if data.series.is_empty() {
7593 return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7594 }
7595 let series: Vec<ChartExportSeries> = data
7596 .series
7597 .iter()
7598 .map(|s| ChartExportSeries {
7599 name: s.name.clone(),
7600 points: s.points.clone(),
7601 })
7602 .collect();
7603 let bounds = ChartExportBounds {
7604 x_min: data.x_min,
7605 x_max: data.x_max,
7606 y_min: 0.0,
7607 y_max: data.y_max,
7608 x_label: column.clone(),
7609 y_label: "Density".to_string(),
7610 x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7611 log_scale: false,
7612 chart_title,
7613 };
7614 match format {
7615 ChartExportFormat::Png => {
7616 write_chart_png(path, &series, ChartType::Line, &bounds)
7617 }
7618 ChartExportFormat::Eps => {
7619 write_chart_eps(path, &series, ChartType::Line, &bounds)
7620 }
7621 }
7622 }
7623 ChartKind::Heatmap => {
7624 let x_column = self
7625 .chart_modal
7626 .effective_heatmap_x_column()
7627 .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap X column selected"))?;
7628 let y_column = self
7629 .chart_modal
7630 .effective_heatmap_y_column()
7631 .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap Y column selected"))?;
7632 let row_limit = self.chart_modal.effective_row_limit();
7633 let data = if let Some(c) = self.chart_cache.heatmap.as_ref().filter(|c| {
7634 c.x_column == *x_column
7635 && c.y_column == *y_column
7636 && c.bins == self.chart_modal.heatmap_bins
7637 && c.row_limit == self.chart_modal.row_limit
7638 }) {
7639 c.data.clone()
7640 } else {
7641 chart_data::prepare_heatmap_data(
7642 &state.lf,
7643 &x_column,
7644 &y_column,
7645 self.chart_modal.heatmap_bins,
7646 row_limit,
7647 )?
7648 };
7649 if data.counts.is_empty() || data.max_count <= 0.0 {
7650 return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7651 }
7652 let bounds = ChartExportBounds {
7653 x_min: data.x_min,
7654 x_max: data.x_max,
7655 y_min: data.y_min,
7656 y_max: data.y_max,
7657 x_label: x_column.clone(),
7658 y_label: y_column.clone(),
7659 x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7660 log_scale: false,
7661 chart_title,
7662 };
7663 match format {
7664 ChartExportFormat::Png => write_heatmap_png(path, &data, &bounds),
7665 ChartExportFormat::Eps => write_heatmap_eps(path, &data, &bounds),
7666 }
7667 }
7668 }
7669 }
7670
7671 fn apply_template(&mut self, template: &Template) -> Result<()> {
7672 let saved_state = self
7674 .data_table_state
7675 .as_ref()
7676 .map(|state| TemplateApplicationState {
7677 lf: state.lf.clone(),
7678 schema: state.schema.clone(),
7679 active_query: state.active_query.clone(),
7680 active_sql_query: state.get_active_sql_query().to_string(),
7681 active_fuzzy_query: state.get_active_fuzzy_query().to_string(),
7682 filters: state.get_filters().to_vec(),
7683 sort_columns: state.get_sort_columns().to_vec(),
7684 sort_ascending: state.get_sort_ascending(),
7685 column_order: state.get_column_order().to_vec(),
7686 locked_columns_count: state.locked_columns_count(),
7687 });
7688 let saved_active_template_id = self.active_template_id.clone();
7689
7690 if let Some(state) = &mut self.data_table_state {
7691 state.error = None;
7692
7693 let sql_trimmed = template.settings.sql_query.as_deref().unwrap_or("").trim();
7695 let query_opt = template.settings.query.as_deref().filter(|s| !s.is_empty());
7696 let fuzzy_trimmed = template
7697 .settings
7698 .fuzzy_query
7699 .as_deref()
7700 .unwrap_or("")
7701 .trim();
7702
7703 if !sql_trimmed.is_empty() {
7704 state.sql_query(template.settings.sql_query.clone().unwrap_or_default());
7705 } else if let Some(q) = query_opt {
7706 state.query(q.to_string());
7707 }
7708 if let Some(error) = state.error.clone() {
7709 if let Some(saved) = saved_state {
7710 self.restore_state(saved);
7711 }
7712 self.active_template_id = saved_active_template_id;
7713 return Err(color_eyre::eyre::eyre!(
7714 "{}",
7715 crate::error_display::user_message_from_polars(&error)
7716 ));
7717 }
7718
7719 if !fuzzy_trimmed.is_empty() {
7720 state.fuzzy_search(template.settings.fuzzy_query.clone().unwrap_or_default());
7721 if let Some(error) = state.error.clone() {
7722 if let Some(saved) = saved_state {
7723 self.restore_state(saved);
7724 }
7725 self.active_template_id = saved_active_template_id;
7726 return Err(color_eyre::eyre::eyre!(
7727 "{}",
7728 crate::error_display::user_message_from_polars(&error)
7729 ));
7730 }
7731 }
7732
7733 if !template.settings.filters.is_empty() {
7735 state.filter(template.settings.filters.clone());
7736 let error_opt = state.error.clone();
7738 if let Some(error) = error_opt {
7739 if let Some(saved) = saved_state {
7741 self.restore_state(saved);
7742 }
7743 self.active_template_id = saved_active_template_id;
7744 return Err(color_eyre::eyre::eyre!("{}", error));
7745 }
7746 }
7747
7748 if !template.settings.sort_columns.is_empty() {
7750 state.sort(
7751 template.settings.sort_columns.clone(),
7752 template.settings.sort_ascending,
7753 );
7754 let error_opt = state.error.clone();
7756 if let Some(error) = error_opt {
7757 if let Some(saved) = saved_state {
7759 self.restore_state(saved);
7760 }
7761 self.active_template_id = saved_active_template_id;
7762 return Err(color_eyre::eyre::eyre!("{}", error));
7763 }
7764 }
7765
7766 if let Some(ref spec) = template.settings.pivot {
7768 if let Err(e) = state.pivot(spec) {
7769 if let Some(saved) = saved_state {
7770 self.restore_state(saved);
7771 }
7772 self.active_template_id = saved_active_template_id;
7773 return Err(color_eyre::eyre::eyre!(
7774 "{}",
7775 crate::error_display::user_message_from_report(&e, None)
7776 ));
7777 }
7778 } else if let Some(ref spec) = template.settings.melt {
7779 if let Err(e) = state.melt(spec) {
7780 if let Some(saved) = saved_state {
7781 self.restore_state(saved);
7782 }
7783 self.active_template_id = saved_active_template_id;
7784 return Err(color_eyre::eyre::eyre!(
7785 "{}",
7786 crate::error_display::user_message_from_report(&e, None)
7787 ));
7788 }
7789 }
7790
7791 if !template.settings.column_order.is_empty() {
7793 state.set_column_order(template.settings.column_order.clone());
7794 let error_opt = state.error.clone();
7796 if let Some(error) = error_opt {
7797 if let Some(saved) = saved_state {
7799 self.restore_state(saved);
7800 }
7801 self.active_template_id = saved_active_template_id;
7802 return Err(color_eyre::eyre::eyre!("{}", error));
7803 }
7804 state.set_locked_columns(template.settings.locked_columns_count);
7805 let error_opt = state.error.clone();
7807 if let Some(error) = error_opt {
7808 if let Some(saved) = saved_state {
7810 self.restore_state(saved);
7811 }
7812 self.active_template_id = saved_active_template_id;
7813 return Err(color_eyre::eyre::eyre!("{}", error));
7814 }
7815 }
7816 }
7817
7818 if let Some(path) = &self.path {
7823 let mut updated_template = template.clone();
7824 updated_template.last_used = Some(std::time::SystemTime::now());
7825 updated_template.usage_count += 1;
7826 updated_template.last_matched_file = Some(path.clone());
7827
7828 let _ = self.template_manager.save_template(&updated_template);
7830 }
7831
7832 self.active_template_id = Some(template.id.clone());
7834
7835 Ok(())
7836 }
7837
7838 fn format_export_error(error: &color_eyre::eyre::Report, path: &Path) -> String {
7840 use std::io;
7841
7842 for cause in error.chain() {
7843 if let Some(io_err) = cause.downcast_ref::<io::Error>() {
7844 let msg = crate::error_display::user_message_from_io(io_err, None);
7845 return format!("Cannot write to {}: {}", path.display(), msg);
7846 }
7847 if let Some(pe) = cause.downcast_ref::<polars::prelude::PolarsError>() {
7848 let msg = crate::error_display::user_message_from_polars(pe);
7849 return format!("Export failed: {}", msg);
7850 }
7851 }
7852 let error_str = error.to_string();
7853 let first_line = error_str.lines().next().unwrap_or("Unknown error").trim();
7854 format!("Export failed: {}", first_line)
7855 }
7856
7857 fn export_data_from_df(
7859 df: &mut DataFrame,
7860 path: &Path,
7861 format: ExportFormat,
7862 options: &ExportOptions,
7863 ) -> Result<()> {
7864 use polars::prelude::*;
7865 use std::fs::File;
7866 use std::io::{BufWriter, Write};
7867
7868 match format {
7869 ExportFormat::Csv => {
7870 use polars::prelude::CsvWriter;
7871 if let Some(compression) = options.csv_compression {
7872 let file = File::create(path)?;
7874 let writer: Box<dyn Write> = match compression {
7875 CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7876 file,
7877 flate2::Compression::default(),
7878 )),
7879 CompressionFormat::Zstd => {
7880 Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7881 }
7882 CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7883 file,
7884 bzip2::Compression::default(),
7885 )),
7886 CompressionFormat::Xz => {
7887 Box::new(xz2::write::XzEncoder::new(
7888 file, 6, ))
7890 }
7891 };
7892 CsvWriter::new(writer)
7893 .with_separator(options.csv_delimiter)
7894 .include_header(options.csv_include_header)
7895 .finish(df)?;
7896 } else {
7897 let file = File::create(path)?;
7899 CsvWriter::new(file)
7900 .with_separator(options.csv_delimiter)
7901 .include_header(options.csv_include_header)
7902 .finish(df)?;
7903 }
7904 }
7905 ExportFormat::Parquet => {
7906 use polars::prelude::ParquetWriter;
7907 let file = File::create(path)?;
7908 let mut writer = BufWriter::new(file);
7909 ParquetWriter::new(&mut writer).finish(df)?;
7910 }
7911 ExportFormat::Json => {
7912 use polars::prelude::JsonWriter;
7913 if let Some(compression) = options.json_compression {
7914 let file = File::create(path)?;
7916 let writer: Box<dyn Write> = match compression {
7917 CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7918 file,
7919 flate2::Compression::default(),
7920 )),
7921 CompressionFormat::Zstd => {
7922 Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7923 }
7924 CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7925 file,
7926 bzip2::Compression::default(),
7927 )),
7928 CompressionFormat::Xz => {
7929 Box::new(xz2::write::XzEncoder::new(
7930 file, 6, ))
7932 }
7933 };
7934 JsonWriter::new(writer)
7935 .with_json_format(JsonFormat::Json)
7936 .finish(df)?;
7937 } else {
7938 let file = File::create(path)?;
7940 JsonWriter::new(file)
7941 .with_json_format(JsonFormat::Json)
7942 .finish(df)?;
7943 }
7944 }
7945 ExportFormat::Ndjson => {
7946 use polars::prelude::{JsonFormat, JsonWriter};
7947 if let Some(compression) = options.ndjson_compression {
7948 let file = File::create(path)?;
7950 let writer: Box<dyn Write> = match compression {
7951 CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7952 file,
7953 flate2::Compression::default(),
7954 )),
7955 CompressionFormat::Zstd => {
7956 Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7957 }
7958 CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7959 file,
7960 bzip2::Compression::default(),
7961 )),
7962 CompressionFormat::Xz => {
7963 Box::new(xz2::write::XzEncoder::new(
7964 file, 6, ))
7966 }
7967 };
7968 JsonWriter::new(writer)
7969 .with_json_format(JsonFormat::JsonLines)
7970 .finish(df)?;
7971 } else {
7972 let file = File::create(path)?;
7974 JsonWriter::new(file)
7975 .with_json_format(JsonFormat::JsonLines)
7976 .finish(df)?;
7977 }
7978 }
7979 ExportFormat::Ipc => {
7980 use polars::prelude::IpcWriter;
7981 let file = File::create(path)?;
7982 let mut writer = BufWriter::new(file);
7983 IpcWriter::new(&mut writer).finish(df)?;
7984 }
7985 ExportFormat::Avro => {
7986 use polars::io::avro::AvroWriter;
7987 let file = File::create(path)?;
7988 let mut writer = BufWriter::new(file);
7989 AvroWriter::new(&mut writer).finish(df)?;
7990 }
7991 }
7992
7993 Ok(())
7994 }
7995
7996 #[allow(dead_code)] fn export_data(
7998 state: &DataTableState,
7999 path: &Path,
8000 format: ExportFormat,
8001 options: &ExportOptions,
8002 ) -> Result<()> {
8003 let mut df = crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)?;
8004 Self::export_data_from_df(&mut df, path, format, options)
8005 }
8006
8007 fn restore_state(&mut self, saved: TemplateApplicationState) {
8008 if let Some(state) = &mut self.data_table_state {
8009 let saved_lf = saved.lf.clone();
8011 let saved_schema = saved.schema.clone();
8012
8013 state.lf = saved.lf;
8016 state.schema = saved.schema;
8017 state.active_query = saved.active_query;
8018 state.active_sql_query = saved.active_sql_query;
8019 state.active_fuzzy_query = saved.active_fuzzy_query;
8020 state.error = None;
8022 state.filter(saved.filters.clone());
8026 if state.error.is_none() {
8027 state.sort(saved.sort_columns.clone(), saved.sort_ascending);
8028 }
8029 if state.error.is_none() {
8030 state.set_column_order(saved.column_order.clone());
8031 }
8032 if state.error.is_none() {
8033 state.set_locked_columns(saved.locked_columns_count);
8034 }
8035 state.lf = saved_lf;
8037 state.schema = saved_schema;
8038 state.collect();
8039 }
8040 }
8041
8042 pub fn create_template_from_current_state(
8043 &mut self,
8044 name: String,
8045 description: Option<String>,
8046 match_criteria: template::MatchCriteria,
8047 ) -> Result<template::Template> {
8048 let settings = if let Some(state) = &self.data_table_state {
8049 let (query, sql_query, fuzzy_query) = active_query_settings(
8050 state.get_active_query(),
8051 state.get_active_sql_query(),
8052 state.get_active_fuzzy_query(),
8053 );
8054 template::TemplateSettings {
8055 query,
8056 sql_query,
8057 fuzzy_query,
8058 filters: state.get_filters().to_vec(),
8059 sort_columns: state.get_sort_columns().to_vec(),
8060 sort_ascending: state.get_sort_ascending(),
8061 column_order: state.get_column_order().to_vec(),
8062 locked_columns_count: state.locked_columns_count(),
8063 pivot: state.last_pivot_spec().cloned(),
8064 melt: state.last_melt_spec().cloned(),
8065 }
8066 } else {
8067 template::TemplateSettings {
8068 query: None,
8069 sql_query: None,
8070 fuzzy_query: None,
8071 filters: Vec::new(),
8072 sort_columns: Vec::new(),
8073 sort_ascending: true,
8074 column_order: Vec::new(),
8075 locked_columns_count: 0,
8076 pivot: None,
8077 melt: None,
8078 }
8079 };
8080
8081 self.template_manager
8082 .create_template(name, description, match_criteria, settings)
8083 }
8084
8085 fn get_help_info(&self) -> (String, String) {
8086 let (title, content) = match self.input_mode {
8087 InputMode::Normal => ("Main View Help", help_strings::main_view()),
8088 InputMode::Editing => match self.input_type {
8089 Some(InputType::Search) => ("Query Help", help_strings::query()),
8090 _ => ("Editing Help", help_strings::editing()),
8091 },
8092 InputMode::SortFilter => ("Sort & Filter Help", help_strings::sort_filter()),
8093 InputMode::PivotMelt => ("Pivot / Melt Help", help_strings::pivot_melt()),
8094 InputMode::Export => ("Export Help", help_strings::export()),
8095 InputMode::Info => ("Info Panel Help", help_strings::info_panel()),
8096 InputMode::Chart => ("Chart Help", help_strings::chart()),
8097 };
8098 (title.to_string(), content.to_string())
8099 }
8100}
8101
8102impl Widget for &mut App {
8103 fn render(self, area: Rect, buf: &mut Buffer) {
8104 self.debug.num_frames += 1;
8105 if self.debug.enabled {
8106 self.debug.show_help_at_render = self.show_help;
8107 }
8108
8109 use crate::render::context::RenderContext;
8110 use crate::render::layout::{app_layout, centered_rect_loading};
8111 use crate::render::main_view::MainViewContent;
8112
8113 let ctx = RenderContext::from_theme_and_config(
8114 &self.theme,
8115 self.table_cell_padding,
8116 self.column_colors,
8117 );
8118
8119 let main_view_content = MainViewContent::from_app_state(
8120 self.analysis_modal.active,
8121 self.input_mode == InputMode::Chart,
8122 );
8123
8124 Clear.render(area, buf);
8125 let background_color = self.color("background");
8126 Block::default()
8127 .style(Style::default().bg(background_color))
8128 .render(area, buf);
8129
8130 let app_layout = app_layout(area, self.debug.enabled);
8131 let main_area = app_layout.main_view;
8132 Clear.render(main_area, buf);
8133
8134 crate::render::main_view_render::render_main_view(area, main_area, buf, self, &ctx);
8135
8136 if matches!(self.loading_state, LoadingState::Loading { .. }) {
8138 if let LoadingState::Loading {
8139 current_phase,
8140 progress_percent,
8141 ..
8142 } = &self.loading_state
8143 {
8144 let popover_rect = centered_rect_loading(area);
8145 crate::render::overlays::render_loading_gauge(
8146 popover_rect,
8147 buf,
8148 "Loading",
8149 current_phase,
8150 *progress_percent,
8151 ctx.modal_border,
8152 ctx.primary_chart_series_color,
8153 );
8154 }
8155 }
8156 if matches!(self.loading_state, LoadingState::Exporting { .. }) {
8157 if let LoadingState::Exporting {
8158 file_path,
8159 current_phase,
8160 progress_percent,
8161 } = &self.loading_state
8162 {
8163 let label = format!("{}: {}", current_phase, file_path.display());
8164 crate::render::overlays::render_loading_gauge(
8165 area,
8166 buf,
8167 "Exporting",
8168 &label,
8169 *progress_percent,
8170 ctx.modal_border,
8171 ctx.primary_chart_series_color,
8172 );
8173 }
8174 }
8175
8176 if self.confirmation_modal.active {
8177 crate::render::overlays::render_confirmation_modal(
8178 area,
8179 buf,
8180 &self.confirmation_modal,
8181 &ctx,
8182 );
8183 }
8184 if self.success_modal.active {
8185 crate::render::overlays::render_success_modal(area, buf, &self.success_modal, &ctx);
8186 }
8187 if self.error_modal.active {
8188 crate::render::overlays::render_error_modal(area, buf, &self.error_modal, &ctx);
8189 }
8190 if self.show_help
8191 || (self.template_modal.active && self.template_modal.show_help)
8192 || (self.analysis_modal.active && self.analysis_modal.show_help)
8193 {
8194 let (title, text): (String, String) =
8195 if self.analysis_modal.active && self.analysis_modal.show_help {
8196 crate::render::analysis_view::help_title_and_text(&self.analysis_modal)
8197 } else if self.template_modal.active {
8198 (
8199 "Template Help".to_string(),
8200 help_strings::template().to_string(),
8201 )
8202 } else {
8203 let (t, txt) = self.get_help_info();
8204 (t.to_string(), txt.to_string())
8205 };
8206 crate::render::overlays::render_help_overlay(
8207 area,
8208 buf,
8209 &title,
8210 &text,
8211 &mut self.help_scroll,
8212 &ctx,
8213 );
8214 }
8215
8216 let row_count = self.data_table_state.as_ref().map(|s| s.num_rows);
8217 let use_unicode_throbber = std::env::var("LANG")
8218 .map(|l| l.to_uppercase().contains("UTF-8"))
8219 .unwrap_or(false);
8220 let mut controls = Controls::from_context(row_count.unwrap_or(0), &ctx)
8221 .with_unicode_throbber(use_unicode_throbber);
8222
8223 match crate::render::main_view::control_bar_spec(self, main_view_content) {
8224 crate::render::main_view::ControlBarSpec::Datatable {
8225 dimmed,
8226 query_active,
8227 } => {
8228 controls = controls.with_dimmed(dimmed).with_query_active(query_active);
8229 }
8230 crate::render::main_view::ControlBarSpec::Custom(pairs) => {
8231 controls = controls.with_custom_controls(pairs);
8232 }
8233 }
8234
8235 if self.busy {
8236 self.throbber_frame = self.throbber_frame.wrapping_add(1);
8237 }
8238 controls = controls.with_busy(self.busy, self.throbber_frame);
8239 controls.render(app_layout.control_bar, buf);
8240 if let Some(debug_area) = app_layout.debug {
8241 self.debug.render(debug_area, buf);
8242 }
8243 }
8244}
8245
8246pub fn run(input: RunInput, config: Option<AppConfig>, debug: bool) -> Result<()> {
8248 use std::io::Write;
8249 use std::sync::{mpsc, Mutex, Once};
8250
8251 let config = match config {
8252 Some(c) => c,
8253 None => AppConfig::load(APP_NAME)?,
8254 };
8255
8256 let theme = Theme::from_config(&config.theme)
8257 .or_else(|e| Theme::from_config(&AppConfig::default().theme).map_err(|_| e))?;
8258
8259 static COLOR_EYRE_INIT: Once = Once::new();
8262 static INSTALL_RESULT: Mutex<Option<Result<(), color_eyre::Report>>> = Mutex::new(None);
8263 COLOR_EYRE_INIT.call_once(|| {
8264 *INSTALL_RESULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(color_eyre::install());
8265 });
8266 if let Some(Err(e)) = INSTALL_RESULT
8267 .lock()
8268 .unwrap_or_else(|e| e.into_inner())
8269 .as_ref()
8270 {
8271 return Err(color_eyre::eyre::eyre!(e.to_string()));
8272 }
8273 if let RunInput::Paths(ref paths, _) = input {
8275 if paths.is_empty() {
8276 return Err(color_eyre::eyre::eyre!("At least one path is required"));
8277 }
8278 for path in paths {
8279 let s = path.to_string_lossy();
8280 let is_remote = s.starts_with("s3://")
8281 || s.starts_with("gs://")
8282 || s.starts_with("http://")
8283 || s.starts_with("https://");
8284 let is_glob = s.contains('*');
8285 if !is_remote && !is_glob && !path.exists() {
8286 return Err(std::io::Error::new(
8287 std::io::ErrorKind::NotFound,
8288 format!("File not found: {}", path.display()),
8289 )
8290 .into());
8291 }
8292 }
8293 }
8294 let mut terminal = ratatui::try_init().map_err(|e| {
8295 color_eyre::eyre::eyre!(
8296 "datui requires an interactive terminal (TTY). No terminal detected: {}. \
8297 Run from a terminal or ensure stdout is connected to a TTY.",
8298 e
8299 )
8300 })?;
8301 let (tx, rx) = mpsc::channel::<AppEvent>();
8302 let mut app = App::new_with_config(tx.clone(), theme, config.clone());
8303 if debug {
8304 app.enable_debug();
8305 }
8306
8307 terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8308
8309 match input {
8310 RunInput::Paths(paths, opts) => {
8311 tx.send(AppEvent::Open(paths, opts))?;
8312 }
8313 RunInput::LazyFrame(lf, opts) => {
8314 app.set_loading_phase("Scanning input", 10);
8317 terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8318 let _ = std::io::stdout().flush();
8319 std::thread::sleep(std::time::Duration::from_millis(150));
8321 tx.send(AppEvent::OpenLazyFrame(lf, opts))?;
8322 }
8323 }
8324
8325 loop {
8328 let event = match rx.recv_timeout(std::time::Duration::from_millis(50)) {
8329 Ok(ev) => ev,
8330 Err(mpsc::RecvTimeoutError::Timeout) => break,
8331 Err(mpsc::RecvTimeoutError::Disconnected) => break,
8332 };
8333 match event {
8334 AppEvent::Exit => break,
8335 AppEvent::Crash(msg) => {
8336 ratatui::restore();
8337 return Err(color_eyre::eyre::eyre!(msg));
8338 }
8339 ev => {
8340 if let Some(next) = app.event(&ev) {
8341 let _ = tx.send(next);
8342 }
8343 terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8344 let _ = std::io::stdout().flush();
8345 if matches!(ev, AppEvent::DoLoadSchema(..)) {
8348 break;
8349 }
8350 }
8351 }
8352 }
8353
8354 loop {
8355 if crossterm::event::poll(std::time::Duration::from_millis(
8356 config.performance.event_poll_interval_ms,
8357 ))? {
8358 match crossterm::event::read()? {
8359 crossterm::event::Event::Key(key) => {
8360 if key.is_press() {
8361 tx.send(AppEvent::Key(key))?
8362 }
8363 }
8364 crossterm::event::Event::Resize(cols, rows) => {
8365 tx.send(AppEvent::Resize(cols, rows))?
8366 }
8367 _ => {}
8368 }
8369 }
8370
8371 let updated = match rx.recv_timeout(std::time::Duration::from_millis(0)) {
8372 Ok(event) => {
8373 match event {
8374 AppEvent::Exit => break,
8375 AppEvent::Crash(msg) => {
8376 ratatui::restore();
8377 return Err(color_eyre::eyre::eyre!(msg));
8378 }
8379 event => {
8380 if let Some(next) = app.event(&event) {
8381 tx.send(next)?;
8382 }
8383 }
8384 }
8385 true
8386 }
8387 Err(mpsc::RecvTimeoutError::Timeout) => false,
8388 Err(mpsc::RecvTimeoutError::Disconnected) => break,
8389 };
8390
8391 if updated {
8392 terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8393 if app.should_drain_keys() {
8394 while crossterm::event::poll(std::time::Duration::from_millis(0))? {
8395 let _ = crossterm::event::read();
8396 }
8397 app.clear_drain_keys_request();
8398 }
8399 }
8400 }
8401
8402 ratatui::restore();
8403 Ok(())
8404}