Skip to main content

datui_lib/
lib.rs

1use color_eyre::Result;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use polars::datatypes::DataType;
4#[cfg(feature = "cloud")]
5use polars::io::cloud::{AmazonS3ConfigKey, CloudOptions};
6use polars::prelude::{DataFrame, LazyFrame, Schema, col};
7#[cfg(feature = "cloud")]
8use polars::prelude::{PlRefPath, ScanArgsParquet};
9use std::collections::HashMap;
10
11/// Rows measured per background pass. Small enough that a slow filesystem shows
12/// progress rather than a long silence.
13const MEASURE_BATCH: usize = 12;
14
15/// Rows a probe measures while it is already reading a remote directory.
16const PROBE_MEASURE_LIMIT: usize = 24;
17
18/// Rows one classification pass looks into.
19///
20/// A cap on work in flight rather than a budget spent per directory: what gets looked
21/// into is what is on screen, and the next pass is chosen from the viewport as it is
22/// when the previous one lands. Sized like [`MEASURE_BATCH`], for the same reason —
23/// on a share that answers in milliseconds per row, a screenful arriving in pieces
24/// reads as filling in, and one long silence reads as broken.
25const CLASSIFY_BATCH: usize = 16;
26
27/// Probes allowed at once. A probe of a share that has gone away holds its thread
28/// until the process exits, so the number of them has to be bounded.
29const MAX_CONCURRENT_PROBES: usize = 4;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, Mutex, mpsc::Sender};
32use widgets::info::{FileFacts, InfoModal};
33
34use ratatui::style::{Color, Style};
35use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
36
37use ratatui::widgets::{Block, Clear};
38
39mod analysis_keys;
40pub mod analysis_modal;
41pub mod audio;
42pub mod avro_types;
43#[cfg(feature = "cloud")]
44pub mod aws_profiles;
45#[cfg(feature = "cloud")]
46pub mod azure;
47mod background;
48pub mod cache;
49pub mod candump;
50pub mod canonical;
51pub mod catalog;
52pub mod chart_data;
53pub mod chart_export;
54pub mod chart_export_modal;
55mod chart_jobs;
56mod chart_keys;
57pub mod chart_modal;
58mod chart_pdf;
59mod chart_recipe;
60pub mod cli;
61pub mod clipboard;
62#[cfg(feature = "cloud")]
63mod cloud_arrow;
64#[cfg(feature = "cloud")]
65pub mod cloud_browse;
66#[cfg(feature = "cloud")]
67pub mod cloud_command;
68pub mod cloud_env;
69#[cfg(feature = "cloud")]
70mod cloud_hive;
71#[cfg(feature = "cloud")]
72pub mod cloud_sources;
73pub mod codebook;
74pub mod column_types;
75pub mod commands;
76pub mod config;
77pub mod config_command;
78pub mod context_menu;
79mod copy_keys;
80pub mod copy_modal;
81pub mod csv_dialect;
82pub mod data_quality;
83pub mod dataflash;
84mod dataset_files;
85pub mod dbc;
86pub mod delimited_spec;
87pub mod discover;
88pub mod distribution_fit;
89pub mod download;
90mod editing_keys;
91pub mod elf;
92pub mod error_display;
93pub mod event_pump;
94pub mod exact;
95pub mod excel;
96pub mod export;
97mod export_keys;
98pub mod export_modal;
99pub mod external_open;
100mod feedback;
101pub mod filter_modal;
102pub mod find;
103mod first_rows_trace;
104pub mod fix;
105pub mod fixed_records;
106pub mod follow;
107mod footer_state;
108pub mod form;
109pub mod formats;
110pub mod framed_records;
111pub mod fuzzy;
112#[cfg(feature = "cloud")]
113pub mod gcloud;
114pub mod glyphs;
115pub mod gps;
116pub mod help;
117mod hex_keys;
118pub mod hex_view;
119pub mod hf_splits;
120pub mod home;
121pub mod home_preview;
122pub mod indexed;
123mod info_keys;
124pub mod inspector_bytes;
125pub mod inspector_drill;
126pub mod inspector_modal;
127pub mod inspector_reader;
128pub mod intent_modal;
129pub mod ipc_stream;
130mod jobs;
131pub mod journal;
132pub mod lines;
133pub mod link_open;
134mod loading;
135pub mod local_copy;
136pub(crate) mod local_glob;
137pub mod locality;
138pub mod logging;
139pub mod measurements;
140pub mod members;
141pub mod midi;
142pub mod model_files;
143pub mod nested_json;
144pub mod notes;
145pub mod nul_tail;
146pub mod numfmt;
147pub mod numpy;
148mod open_options;
149pub mod output_file;
150pub mod parquet_footer;
151pub mod past_calendar;
152mod pivot_melt_keys;
153pub mod pivot_melt_modal;
154pub mod pointer;
155pub mod pushdown;
156pub mod python_script;
157pub mod quality_export;
158pub mod quality_intent;
159mod quality_memory;
160pub mod quality_report;
161pub mod quality_trends;
162#[cfg(any(feature = "http", feature = "cloud"))]
163mod remote_model;
164mod retype_keys;
165pub mod retype_modal;
166pub mod row_index;
167#[cfg(feature = "cloud")]
168pub mod s3_tools;
169mod sample_keys;
170pub mod sample_modal;
171pub mod sampling;
172pub mod table_sample;
173// Public so the fuzz targets in `fuzz/` can reach `parse_query`. The parser is
174// hand-written and runs on whatever the user types, so it is fuzzed directly.
175pub mod query;
176mod readers;
177mod render;
178pub mod sanitize;
179mod scan;
180pub mod schema_union;
181pub mod sdf;
182pub mod search;
183pub(crate) mod segments;
184mod sort_filter_keys;
185pub mod sort_filter_modal;
186pub mod sort_modal;
187pub mod source;
188pub(crate) mod spec_union;
189mod sql_assist;
190pub mod sqlite;
191// Public so the fuzz target `sql_group_plan` can reach `plan`, which reads every SQL
192// statement the prompt runs.
193#[cfg(feature = "sql")]
194pub mod sql_group;
195pub mod startup;
196pub mod statistics;
197pub mod stdin;
198pub mod table_switch;
199pub mod tee;
200mod terminal;
201mod terminal_color;
202pub mod terminal_input;
203pub mod text_formats;
204pub mod themes;
205pub mod typed_value;
206pub mod ulog;
207mod unfinished;
208pub mod user_agent;
209pub mod value_counts;
210pub mod value_counts_modal;
211pub mod vcd;
212pub mod view;
213mod view_keys;
214pub mod widgets;
215
216pub use cache::CacheManager;
217pub use cli::Args;
218pub use config::{
219    AppConfig, ColorParser, ConfigManager, QueryMode, Theme, ThemeMode, rgb_to_256_color,
220    rgb_to_basic_ansi,
221};
222
223use analysis_modal::{AnalysisModal, AnalysisProgress};
224use background::{CacheWrites, InflightCollect, LenCount, OwedAnswer, OwedCount};
225use chart_export::{ChartExportFormat, ChartExportRequest};
226use chart_export_modal::ChartExportModal;
227use chart_jobs::{
228    ChartCache, ChartExportJob, ChartInflight, ChartPrepared, ChartRequest, ChartResultSlot,
229    log_series,
230};
231use chart_modal::{ChartColumns, ChartModal};
232pub use error_display::{ErrorKindForPython, error_for_python};
233pub use export::{ExportOptions, ExportRequest};
234use export_modal::{ExportFocus, ExportFormat, ExportModal};
235pub use feedback::{ConfirmationModal, ErrorModal, Flash};
236use filter_modal::{FilterOperator, FilterStatement, LogicalOperator};
237use form::FormKey;
238use jobs::{Answer, Job, Jobs, Outcome};
239pub use jobs::{JobKind, Progress, Ticket};
240use numfmt::NumberFormatSettings;
241pub use open_options::{
242    OpenOptions, ParseStringsTarget, ReadReport, SqliteOpen, TypedDialect, UnaskedDownload,
243};
244use output_file::Overwrite;
245use pivot_melt_modal::{MeltSpec, PivotMeltModal, PivotSpec};
246pub use quality_memory::{KeptQualitySample, QUALITY_MEMORY_BUDGET, RetainedCopy};
247use quality_memory::{QUALITY_RELEASED_REMEMBERED, QualityCacheEntry, QualityCopyJob};
248use scan::Scan;
249use sort_filter_modal::SortFilterModal;
250use sort_modal::{SortColumn, order_with_hidden};
251use terminal::{QuietTerminal, TakenTerminal, follow_focus, push_keyboard_flags, restore_terminal};
252pub use unfinished::ExitSweep;
253pub use view::{SavedView, ViewManager, Views};
254use widgets::column_widths::WidthChoice;
255use widgets::datatable::{DataTableState, DrillRow, OpenFacts};
256use widgets::debug::DebugState;
257use widgets::text_input::TextInput;
258use widgets::view_modal::{FormFocus, ViewModal, ViewModalMode, ViewRow};
259
260/// Application name used for cache directory and other app-specific paths
261pub const APP_NAME: &str = "datui";
262
263/// What a file no reader takes, and no hex view can show, is told.
264pub(crate) const UNSUPPORTED: &str =
265    "Unsupported file type. --format names the format to read it as.";
266
267/// Re-export compression format and file format from CLI module
268pub use cli::{CompressionFormat, FileFormat, ReadMode, RemoteRead, Stored, Summary};
269
270#[cfg(test)]
271mod text_input_flows;
272
273#[cfg(test)]
274pub mod tests;
275
276pub enum AppEvent {
277    Key(KeyEvent),
278    /// A key to take as if typed: what Enter on a help line presses. The event pump
279    /// offers it as the next typed key, through `classify`, so it is held, converted or
280    /// dropped as a typed key would be; outside the pump it is a `Key`.
281    Press(KeyEvent),
282    /// Read from the terminal by [`terminal_input::TerminalInput`]: a key press or a
283    /// resize. [`event_pump::EventPump`] takes it off the channel and decides what a
284    /// key does while the app is busy; the app itself only ever sees `Key`/`Resize`.
285    Terminal(crossterm::event::Event),
286    /// Something polled rather than sent changed (a background panic, a Polars
287    /// warning): the loop should look. Handled as nothing.
288    Wake,
289    /// The terminal said what its background is (an OSC 11 reply, taken off the input
290    /// stream by [`terminal_input`]). Under `theme.mode = "auto"` the palette follows.
291    TerminalBackground(ThemeMode),
292    /// The terminal window came back into focus: under `auto` the background is asked
293    /// again, since the scheme may have changed while it was away.
294    TerminalFocused,
295    /// The settings `run` reads on a worker before it can build the app. Never reaches
296    /// the app: `run` waits for it before there is one.
297    SettingsRead(Box<Result<startup::Settings>>),
298    /// The paths named on the command line or by the Python binding: whether each is
299    /// there and whether one is a directory is asked on a worker
300    /// ([`JobKind::OpenNamed`]), a local-looking path being no promise of a fast
301    /// mount.
302    OpenNamed(Vec<PathBuf>, OpenOptions),
303    /// A path named on the command line is not there: the session ends as a missing
304    /// file always has. The continuation of the look's answer.
305    NamedPathMissing(PathBuf),
306    /// Open these paths. Its phases are the loading controller's: each worker's answer
307    /// starts the next, and the dataset is installed when its schema is read.
308    Open(Vec<PathBuf>, OpenOptions),
309    /// Open with an existing LazyFrame (e.g. from Python binding); no file load.
310    OpenLazyFrame(Box<LazyFrame>, OpenOptions),
311    /// A home listing built off-thread is ready.
312    HomeListingReady {
313        generation: u64,
314        listing: Box<crate::home::Listing>,
315        /// What earlier runs measured, read from the cache with the listing.
316        known: std::collections::HashMap<PathBuf, crate::cache::DatasetFacts>,
317        /// How often and how lately each recent was opened.
318        visits: std::collections::HashMap<PathBuf, crate::cache::Visits>,
319        /// The recent opened last, where the cursor lands.
320        newest: Option<PathBuf>,
321        /// The saved folds, when entering the home screen asked for them.
322        folds: Option<std::collections::HashMap<String, bool>>,
323    },
324    /// The worker building a home listing panicked, so no listing is coming. The panic
325    /// is flashed like any other raw worker's.
326    HomeListingFailed,
327    /// The directory the `~` prompt is typing, read off-thread.
328    HomePathListed {
329        listing: Box<crate::home::PathListing>,
330    },
331    /// A completed path, worked out off-thread.
332    HomePathCompleted {
333        generation: u64,
334        /// What was typed when completion was asked for; a later keystroke makes the
335        /// answer stale.
336        typed: String,
337        completed: String,
338        candidates: usize,
339    },
340    /// The first rows of the highlighted file, read off-thread the way its open reads
341    /// them, and the dataset that read built, for the open to install.
342    HomePreviewReady {
343        path: PathBuf,
344        /// The row's stamp when it was asked for: what the rows are kept under.
345        stamp: crate::home_preview::Stamp,
346        /// The file's stamp when it was read: what the dataset is installed under.
347        read_at: Option<crate::home_preview::Stamp>,
348        rows: Option<Arc<crate::home_preview::PreviewRows>>,
349        prepared: crate::home_preview::Handoff,
350    },
351    /// A schema read off-thread for the highlighted dataset.
352    HomeSchemaReady {
353        generation: u64,
354        path: PathBuf,
355        preview: Option<crate::discover::SchemaPreview>,
356    },
357    /// Measurements for rows the home screen asked about, sent as each row is read so
358    /// a slow row does not hold back the ones before it. `done` marks the end of the
359    /// batch and frees the slot for the next one.
360    ///
361    /// No generation, unlike its neighbors: what a look found is keyed by path and
362    /// true of that path whichever listing asked, so an answer that outlives its
363    /// listing is still the answer.
364    HomeMeasured {
365        measured: Vec<(PathBuf, crate::home::Measured)>,
366        done: bool,
367    },
368    /// What a HEAD said an HTTP(S) file on home weighs: its row, measured.
369    HomeSized {
370        path: PathBuf,
371        measured: crate::home::Measured,
372    },
373    /// What a HEAD settled about an HTTP(S) file on home: it cannot be had.
374    HomeWebGone {
375        path: PathBuf,
376        gone: crate::error_display::HttpGone,
377    },
378    /// What the rows on screen turned out to be. The same payload as
379    /// [`AppEvent::HomeMeasured`] and folded in the same way: a kind is one of the
380    /// things a look into a row produces.
381    HomeClassified {
382        measured: Vec<(PathBuf, crate::home::Measured)>,
383        done: bool,
384    },
385    /// A batch of datasets found by the background search below the working
386    /// directory. Sent repeatedly while the walk runs, so a cold tree fills in
387    /// rather than arriving all at once at the end.
388    HomeSearchBatch {
389        generation: u64,
390        root: PathBuf,
391        found: Vec<crate::discover::Entry>,
392        scanned: usize,
393    },
394    /// The filter scored against the search's files, for the walk `epoch` names.
395    HomeSearchScored {
396        epoch: u64,
397        /// `None` from a worker that died.
398        matches: Option<Box<crate::search::Matches>>,
399    },
400    /// The background search has stopped, with `limited` saying why if it stopped
401    /// short of walking everything.
402    HomeSearchDone {
403        generation: u64,
404        root: PathBuf,
405        scanned: usize,
406        limited: Option<String>,
407    },
408    /// The cloud sources on this machine, with whatever was listed on an earlier run.
409    /// Sent before anything is fetched, so the rows are there on the first frame.
410    #[cfg(feature = "cloud")]
411    HomeCloudSources {
412        sources: Vec<crate::home::CloudSource>,
413    },
414    /// One source's buckets have been listed, or could not be. Each source reports on
415    /// its own, so a slow endpoint holds up nobody else's row.
416    #[cfg(feature = "cloud")]
417    HomeCloudListed {
418        id: String,
419        buckets: Vec<PathBuf>,
420        /// Lines for the details pane of each listed place that has any.
421        details: Vec<(PathBuf, Vec<(String, String)>)>,
422        /// `(short, detail)` when the listing failed.
423        failure: Option<(String, String)>,
424        listed_at: std::time::SystemTime,
425    },
426    /// A network root has been listed off-thread, or could not be.
427    HomeProbeReady {
428        root: PathBuf,
429        rows: Option<Vec<crate::discover::Entry>>,
430        /// The listing stopped at [`crate::discover::MAX_ENTRIES_PER_DIR`].
431        cut_short: bool,
432    },
433    /// The rows of a network directory read so far, while its listing goes on.
434    HomeProbeProgress {
435        root: PathBuf,
436        rows: Vec<crate::discover::Entry>,
437    },
438    /// What peeking inside some directories of a cloud listing found: the ones that are
439    /// partitioned or Parquet datasets.
440    HomeCloudKinds {
441        kinds: Vec<(
442            PathBuf,
443            (crate::discover::EntryKind, crate::discover::Holds),
444        )>,
445        /// Directories whose peek failed or was lost: not answered, so not labelled as
446        /// if they were.
447        failed: Vec<PathBuf>,
448    },
449    /// A cloud listing stopped because its place was left. Nothing is known about the
450    /// place, so it is listed again when it is entered again.
451    HomeProbeCancelled {
452        root: PathBuf,
453    },
454    /// The names under `prefix` in a cloud directory cut short, asked for by a filter;
455    /// `None` when the listing failed or was stopped.
456    HomeNarrowed {
457        dir: PathBuf,
458        prefix: String,
459        listed: Option<(Vec<crate::discover::Entry>, bool)>,
460    },
461    /// A cloud listing was refused, with the service's reason.
462    HomeProbeFailed {
463        root: PathBuf,
464        message: String,
465    },
466    /// Run the export, from plan to committed file, once the UI has drawn its
467    /// progress.
468    DoExport(ExportRequest),
469    /// A followed file's watcher found more rows, or that the file went.
470    Followed(crate::follow::News),
471    /// The Info tab of a piped journal, read again once it ended, for the dataset of
472    /// that generation.
473    FollowedDetail {
474        dataset_generation: u64,
475        detail: Box<crate::text_formats::Detail>,
476    },
477    Exit,
478    Crash(String),
479    QQuery(String),
480    SqlQuery(String),
481    Filter(Vec<FilterStatement>),
482    Sort(Vec<String>, Vec<bool>), // Columns, and per column whether it runs descending
483    ColumnOrder(Vec<String>, usize), // Column order, locked columns count
484    Pivot(PivotSpec),
485    Melt(MeltSpec),
486    Export(ExportRequest),
487    /// Collect and format the whole view off-thread for a table-scope copy.
488    CopyTable {
489        format: crate::clipboard::CopyFormat,
490        header: bool,
491    },
492    ChartExport(ChartExportRequest),
493    /// A documentation link the user confirmed, checked by `link_open::checked_url`:
494    /// start the browser on it.
495    OpenLink(String),
496    /// Deferred: run the chart export once its phase is drawn.
497    DoChartExport(ChartExportRequest),
498    Collect,
499    Update,
500    Reset,
501    Resize(u16, u16), // resized (width, height)
502    DoScrollDown,     // Deferred scroll: perform page_down after one frame (throbber)
503    DoScrollUp,       // Deferred scroll: perform page_up
504    DoScrollNext,     // Deferred scroll: perform select_next (one row down)
505    DoScrollPrev,     // Deferred scroll: perform select_previous (one row up)
506    DoScrollEnd,      // Deferred scroll: jump to last page (throbber)
507    DoScrollHome,     // Deferred scroll: jump to first page (throbber)
508    DoScrollHalfDown, // Deferred scroll: half page down
509    DoScrollHalfUp,   // Deferred scroll: half page up
510    GoToLine(usize),  // Deferred: jump to line number (when collect needed)
511    /// Run the next chunk of analysis (describe/distribution); drives per-column progress.
512    AnalysisChunk,
513    /// Run distribution analysis (deferred so progress overlay can show first).
514    AnalysisDistributionCompute,
515    /// Run correlation matrix (deferred so progress overlay can show first).
516    AnalysisCorrelationCompute,
517    /// Run the configured data-quality plan off the UI thread.
518    AnalysisDataQualityCompute,
519    /// A Data Quality run that stopped short had already read its sample: kept, so
520    /// the read it paid for is not thrown away.
521    BackgroundQualitySampleKept {
522        kept: KeptQualitySample,
523    },
524    /// A full scan finished copying a remote dataset's objects locally: kept for the
525    /// dataset it was fetched for, whatever becomes of the run. `None` when the copy
526    /// did not read as the source, so later runs read the source and say why.
527    BackgroundQualityCopyKept {
528        dataset_generation: u64,
529        copy: Option<Arc<crate::local_copy::LocalCopy>>,
530    },
531    /// Background task completed: exact row count for the current LazyFrame. Applied to
532    /// `data_table_state` only if `len_generation` still matches (the data is unchanged).
533    /// Runs concurrently with — and independently of — the first buffer paint, so the
534    /// count fills in the scrollbar/total without ever blocking the initial render.
535    BackgroundLenReady {
536        len_generation: u64,
537        num_rows: usize,
538        /// For a remote dataset of many files, the rows in each row group of each file,
539        /// from their footers.
540        file_row_groups: Option<Vec<Vec<usize>>>,
541    },
542    /// Background row count failed. Clears the in-flight marker; the total stays
543    /// provisional and is shown as unknown. Scrolling does not count again; End does.
544    BackgroundLenFailed {
545        len_generation: u64,
546    },
547    /// A frame was painted. The run loop calls [`App::frame_painted`] itself; a harness
548    /// that paints nothing sends this when [`App::count_waits_for_a_frame`].
549    FramePainted,
550    /// Every footer of a dataset that opened from two of them has now been read. What
551    /// they say is in `App::pending_footers_result`; the columns they add join the
552    /// dataset already on screen.
553    BackgroundFootersJoined {
554        generation: u64,
555    },
556    /// Every line of a text file opened from its first rows is indexed, `rows` of
557    /// them, for the dataset of `generation`.
558    LinesIndexed {
559        generation: u64,
560        rows: usize,
561    },
562    /// Background task completed: chart data for one selection is prepared. The data is
563    /// in `App::pending_chart_result`; it belongs to `App::chart_inflight`, which says
564    /// whether it is still wanted.
565    BackgroundChartReady,
566    /// Write the Data Quality report on screen to a file, in a form. From the
567    /// results in memory: nothing is read.
568    QualityReportExport(PathBuf, crate::quality_export::ReportFormat, Overwrite),
569    /// A directory named on the command line: look at it on a worker, then do with it
570    /// whatever `Enter` on its row would do.
571    ///
572    /// The look reads footers, or the front of a spread of files, which for a directory
573    /// of large Parquet is seconds. It is an event rather than a call so the first frame
574    /// is drawn before it starts, and the wait has the directory's name on it, a spinner
575    /// and a way out.
576    LookThenOpenDirectory(PathBuf, OpenOptions),
577    /// Look at a path off the interface thread, then do with it whatever it turns out to
578    /// need — browse into it, say it is a lake table, or open it.
579    ///
580    /// `exists`, `is_dir` and `classify_directory` are all filesystem calls, and the home
581    /// screen is full of paths on mounts that may not answer. Doing them where the keys
582    /// are read is an uninterruptible freeze with Ctrl+C on the same thread.
583    ClassifyThenOpen {
584        path: PathBuf,
585        /// A jump — a path typed at `~` — rather than a row that was already listed. Esc
586        /// then comes back from there to the listing, not up through wherever the path
587        /// happens to sit.
588        jump: bool,
589    },
590    /// A background job's outcome is in its record: [`jobs::Jobs::end`] takes it.
591    JobEnded(Ticket),
592    /// A report from a background job still running.
593    JobProgress {
594        ticket: Ticket,
595        progress: Progress,
596    },
597}
598
599impl AppEvent {
600    /// A report from work still going, sent many times while it runs: the run loop may
601    /// fold several into one frame. Anything else is drawn as soon as it is handled.
602    pub fn is_progress(&self) -> bool {
603        matches!(
604            self,
605            AppEvent::HomeMeasured { done: false, .. }
606                | AppEvent::HomeClassified { done: false, .. }
607                | AppEvent::HomeSearchBatch { .. }
608                | AppEvent::HomeProbeProgress { .. }
609                | AppEvent::JobProgress {
610                    progress: Progress::QualityPhase(_),
611                    ..
612                }
613        )
614    }
615}
616
617/// Picks the home-screen workers that panic before their work starts, by the answer
618/// they would owe; see `App::home_worker_dies`.
619#[cfg(test)]
620type HomeWorkerDies = Box<dyn FnMut(&AppEvent) -> bool + Send>;
621
622/// Stands in for [`FileFacts::read`]; see `App::file_facts_reader`.
623#[cfg(test)]
624type FileFactsReader = Arc<
625    dyn Fn(&Path, Option<crate::readers::Facts>) -> std::result::Result<FileFacts, String>
626        + Send
627        + Sync,
628>;
629
630/// What [`App::handle`] did with an event: `Ok` carries the follow-up event to send,
631/// if any; `Err` returns a key that arrived while the app was busy. Nothing was done
632/// with that key and it was not dropped: the caller keeps it and offers it again once
633/// the app is idle.
634pub type EventOutcome = Result<Option<AppEvent>, KeyEvent>;
635
636/// What <kbd>Enter</kbd> will do on the highlighted row.
637///
638/// Written so the control bar and the details pane can say it before it happens.
639/// Every directory has two doors and the labels no longer decide access, which is only
640/// worth anything if the screen says which key is which — a bar reading `Enter Open` on
641/// a row where `Enter` goes inside teaches the wrong thing on the first try, and the
642/// first try is the one that forms the impression.
643///
644/// A prediction, so it can drift from [`App::home_open_selected`], which is the thing
645/// that actually decides. `test_the_bar_says_what_enter_will_really_do` pumps `Enter`
646/// on one row of every shape and asserts the two agreed; that test is the reason this
647/// is safe to read from the renderer.
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub enum WhatEnter {
650    /// Load the file on the row.
651    OpensFile,
652    /// Read the whole directory as one table: a hive root, a directory whose files are
653    /// one table, or the `(all files)` row.
654    OpensDirectory,
655    /// Step into the directory. What `→` does too, on these rows.
656    GoesInside,
657    /// Look at the row first, then do whichever of the above the answer calls for.
658    LooksFirst,
659    /// Fold or unfold a section.
660    FoldsSection,
661    /// Show the rest of `RECENT`.
662    ShowsMore,
663    /// Show the files datui cannot open, as `Ctrl+A` does.
664    ShowsHidden,
665    /// Nothing to open and nowhere to go: an HTTP place, which has no listing to
666    /// browse and says so.
667    Explains,
668    /// A local file datui has no reader for: Enter shows its bytes in the hex view.
669    OpensHex,
670    /// A remote file datui has no reader for. Enter says so, and the bar offers nothing.
671    Nothing,
672}
673
674impl App {
675    /// See [`WhatEnter`].
676    pub fn what_enter_does(&self) -> WhatEnter {
677        // One walk of the list, not four. Every `selected_*` helper rebuilds it, and this
678        // runs from the control bar on every frame, beside a
679        // `selected_directory_to_enter` that walks it once more.
680        let rows = self.home.visible();
681        let entry = match rows.get(self.home.selected) {
682            // A place row browses into the place, which is what `→` does on it too, so
683            // it is labelled the same and offered once. An HTTP place has no listing to
684            // browse and says so instead.
685            Some(home::Row::Place { path, .. }) => {
686                return if home::place_is_browsable(path) {
687                    WhatEnter::GoesInside
688                } else {
689                    WhatEnter::Explains
690                };
691            }
692            Some(home::Row::Header { .. }) => return WhatEnter::FoldsSection,
693            Some(home::Row::More { .. }) => return WhatEnter::ShowsMore,
694            Some(home::Row::Hidden { .. }) => return WhatEnter::ShowsHidden,
695            // "No match.": nothing to open and nothing to say about it.
696            None => return WhatEnter::Nothing,
697            // The door reads the directory it names whatever that directory is labelled —
698            // the lake tables included, which is the one row that reads them at all.
699            Some(home::Row::Door { .. }) => return WhatEnter::OpensDirectory,
700            Some(home::Row::Entry { entry, .. }) => *entry,
701        };
702        // A bookmark opens whole.
703        if entry.kind != discover::EntryKind::File && self.home.bookmark(&entry.path).is_some() {
704            return WhatEnter::OpensDirectory;
705        }
706        match entry.kind {
707            discover::EntryKind::Unknown => WhatEnter::LooksFirst,
708            // A database of several tables lists them.
709            discover::EntryKind::File if entry.enter_lists_tables() => WhatEnter::GoesInside,
710            discover::EntryKind::File => WhatEnter::OpensFile,
711            discover::EntryKind::Other
712                if matches!(
713                    source::input_source(&entry.path),
714                    source::InputSource::Local(_)
715                ) =>
716            {
717                WhatEnter::OpensHex
718            }
719            discover::EntryKind::Other => WhatEnter::Nothing,
720            discover::EntryKind::Hive | discover::EntryKind::MultiFile => WhatEnter::OpensDirectory,
721            // A plain directory, and a lake table, whose files are not its rows.
722            discover::EntryKind::Directory
723            | discover::EntryKind::Delta
724            | discover::EntryKind::Iceberg
725            | discover::EntryKind::Hudi => WhatEnter::GoesInside,
726        }
727    }
728}
729
730impl App {
731    /// Whether a dataset held `download` because it came from a remote `path` (the
732    /// URL it is shown by): a local stream's conversion and standard input's spool are
733    /// held the same way.
734    fn fetched(download: Option<&crate::download::TempDownload>, path: Option<&Path>) -> bool {
735        download.is_some() && path.is_some_and(source::is_remote_url)
736    }
737}
738
739/// Input for the shared run loop: open from file paths or from an existing LazyFrame (e.g. Python binding).
740#[derive(Clone)]
741pub enum RunInput {
742    /// The command line as parsed. The configuration is read, and the flags applied
743    /// over it, behind the first frame ([`startup`]).
744    Cli(Box<Args>),
745    /// A host program's options, as the command line would give them, with a frame
746    /// to show instead of its paths when there is one. Read as the command line is,
747    /// `-c` included, but standard input is the host's, never data.
748    Host(Box<Args>, Option<Box<LazyFrame>>),
749    Paths(Vec<PathBuf>, OpenOptions),
750    LazyFrame(Box<LazyFrame>, OpenOptions),
751}
752
753#[derive(Debug, Default, PartialEq, Eq)]
754pub enum InputMode {
755    #[default]
756    Normal,
757    /// The home screen: pick a dataset to open. Reachable at startup with no
758    /// arguments, and from inside a session, which is what makes datui a place you
759    /// stay rather than a command you re-run.
760    Home,
761    SortFilter,
762    PivotMelt,
763    Editing,
764    Export,
765    /// The copy dialog over the table.
766    Copy,
767    /// The row inspector over the table.
768    Inspect,
769    /// The column picker over the table: type a column's name to go to it.
770    GoToColumn,
771    /// The format picker over a table read through a spec: read it with another.
772    PickFormat,
773    /// A column's type, picked over the table: from the Info panel's Schema tab or
774    /// the cell menu.
775    Retype,
776    /// A datetime made from columns, as a spec's derived column.
777    Combine,
778    /// The table picker over a table of a file of several: open another.
779    PickTable,
780    Info,
781    Chart,
782    /// Value Counts: how often each value of one column occurs in the view.
783    ValueCounts,
784    /// The hex view: a file's bytes.
785    Hex,
786    /// The Sample form over the table (`S`).
787    Sample,
788}
789
790#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub enum InputType {
792    /// The command line (`:`): a row number, or a query in SQL or q.
793    Query,
794    Find,
795}
796
797/// A query whose first rows are being read. It planned, but can still fail on the
798/// data — a value that will not cast — and until the rows are in, the view it
799/// replaced is kept to go back to.
800struct QueryRun {
801    origin: RunOrigin,
802    /// The `len_generation` of the frame the query installed. Once that frame is gone
803    /// (a sort, a filter, another dataset) the rollback no longer applies.
804    frame: u64,
805    rollback: crate::widgets::datatable::ViewRollback,
806    /// The App's count markers as they were, for the frame the rollback restores.
807    /// A count of that frame still running when the query began lands while the
808    /// query's frame is installed; its answer goes into `rollback`.
809    len_count_inflight: Option<u64>,
810    count_after_paint: Option<u64>,
811    len_count_failed: Option<u64>,
812    /// Rows `df` holds, when known, so a failure can say "of N".
813    rows: Option<usize>,
814}
815
816/// Where a running query came from, which decides where its failure is said.
817enum RunOrigin {
818    /// The query prompt, or its event sent directly: inline under the prompt while
819    /// it is open in this mode, else a dialog.
820    Query(QueryMode),
821    /// A view applied. Its failure is a dialog, and the view marked applied before
822    /// it is marked again. Applied for a match rather than picked, `matched` says
823    /// why once its rows are in.
824    View {
825        previous: Option<String>,
826        matched: Option<(String, view::MatchReason)>,
827    },
828}
829
830/// What the bar says of a recording (`--tee`): `rec` with its size and rate while it
831/// goes on, `saved` with its size, length and file once it ended (`sent` and no file
832/// for `--tee -`), or `stopped` and why, in the warning color, when it ended in an
833/// error. The second value is that last.
834fn recording_label(spool: &crate::follow::Spool) -> (String, bool) {
835    let dot = crate::glyphs::get().middot;
836    let size = crate::discover::format_size(spool.bytes());
837    match spool.ended() {
838        None => (
839            format!(
840                "rec {size} {dot} {}/s",
841                crate::discover::format_size(spool.rate() as u64)
842            ),
843            false,
844        ),
845        Some(None) => {
846            let secs = spool.duration().as_secs();
847            let length = if secs >= 3600 {
848                format!("{}:{:02}:{:02}", secs / 3600, secs / 60 % 60, secs % 60)
849            } else {
850                format!("{}:{:02}", secs / 60, secs % 60)
851            };
852            match spool.tee().filter(|tee| !tee.to_stdout()) {
853                Some(tee) => (
854                    format!("saved {size} {dot} {length} {dot} {}", tee.name()),
855                    false,
856                ),
857                None => (format!("sent {size} {dot} {length}"), false),
858            }
859        }
860        Some(Some(reason)) => (format!("stopped: {reason}"), true),
861    }
862}
863
864/// Where a key was taking the user when leaving was asked about.
865#[derive(Debug, Clone, Copy, PartialEq, Eq)]
866enum Leaving {
867    Quit,
868    Home,
869}
870
871/// An export under way, for the control bar: the file, its phase, and the bytes
872/// written once writing has started.
873#[derive(Clone, Debug)]
874pub struct ExportProgress {
875    pub file_path: PathBuf,
876    pub current_phase: String,
877    pub written: Option<u64>,
878}
879
880/// In-progress analysis computation state (orchestration in App; modal only displays progress).
881#[allow(dead_code)]
882struct AnalysisComputationState {
883    df: Option<DataFrame>,
884    schema: Option<Arc<Schema>>,
885    partial_stats: Vec<crate::statistics::ColumnStatistics>,
886    current: usize,
887    total: usize,
888    total_rows: usize,
889    sample_seed: u64,
890    sample_size: Option<usize>,
891}
892
893/// At most one query type can be active. Returns (query, sql_query, fuzzy_query) with only the
894/// active one set (SQL takes precedence over fuzzy over DSL query). Used when saving view settings.
895fn active_query_settings(
896    dsl_query: &str,
897    sql_query: &str,
898    fuzzy_query: &str,
899) -> (Option<String>, Option<String>, Option<String>) {
900    let sql_trimmed = sql_query.trim();
901    let fuzzy_trimmed = fuzzy_query.trim();
902    let dsl_trimmed = dsl_query.trim();
903    if !sql_trimmed.is_empty() {
904        (None, Some(sql_trimmed.to_string()), None)
905    } else if !fuzzy_trimmed.is_empty() {
906        (None, None, Some(fuzzy_trimmed.to_string()))
907    } else if !dsl_trimmed.is_empty() {
908        (Some(dsl_trimmed.to_string()), None, None)
909    } else {
910        (None, None, None)
911    }
912}
913
914/// The steps `state` shows, as a saved view keeps them: the query, filters, sort,
915/// columns and reshape.
916pub(crate) fn view_settings_of(state: &DataTableState) -> view::ViewSettings {
917    let (query, sql_query, fuzzy_query) = active_query_settings(
918        state.get_active_query(),
919        state.get_active_sql_query(),
920        state.get_active_fuzzy_query(),
921    );
922    view::ViewSettings {
923        chart: None,
924        sample: saved_sample_of(state),
925        query,
926        sql_query,
927        fuzzy_query,
928        filters: state.get_filters().to_vec(),
929        sort_columns: state.get_sort_columns().to_vec(),
930        sort_descending: state.get_sort_descending().to_vec(),
931        sort_ascending: state.get_sort_ascending(),
932        column_order: state.get_column_order().to_vec(),
933        locked_columns_count: state.locked_columns_count(),
934        pivot: state.last_pivot_spec().cloned(),
935        melt: state.last_melt_spec().cloned(),
936        reshape_source: state.reshape_source().cloned(),
937        columns: state.column_changes().to_vec(),
938    }
939}
940
941/// The sample `state` is, as a view keeps it: with the query and filters it was
942/// drawn through, when it was drawn from the view's rows.
943fn saved_sample_of(state: &DataTableState) -> Option<view::SavedSample> {
944    let sampled = state.sampled()?;
945    // The whole view it was drawn through: column types, a reshape and a sort pick
946    // its rows as much as a query does.
947    let through = sampled.through().then(|| view::ViewSettings {
948        sample: None,
949        chart: None,
950        ..view_settings_of(sampled.source())
951    });
952    Some(view::SavedSample::of(
953        sampled.sample(),
954        sampled.path(),
955        through,
956    ))
957}
958
959/// How far planning a view's steps got.
960pub(crate) enum Replayed {
961    /// Every step is planned; the view's rows are still to be read.
962    Planned,
963    /// Stopped at the pivot, which has to be read before the steps after it can be
964    /// planned.
965    Pivot(Box<crate::widgets::datatable::PivotJob>),
966}
967
968/// What a cloud open was pointed at: the URL as the user gave it, the prefix to list,
969/// and the glob to keep, where they named one.
970///
971/// Together because they are one thought — where to look — and apart they put this
972/// function's signature past the point where a reader can hold it.
973#[cfg(feature = "cloud")]
974struct CloudTarget<'a> {
975    /// The URL as typed, which is where the bucket and scheme come from.
976    full: &'a str,
977    /// The literal prefix to list: the whole key, or the part of a glob before its star.
978    key: String,
979    /// The glob the user named, where they named one. The listing keeps only the keys
980    /// it matches, so everything downstream sees a plain list of files.
981    pattern: Option<&'a globset::GlobMatcher>,
982}
983
984/// Why Data Quality's Run did not start: a cancelled run has not exited yet. Said
985/// on Setup's line until it has.
986const QUALITY_RUN_WAITS: &str = "Run waits: the cancelled run is still stopping";
987
988/// Why another read of the source did not start: a cancelled analysis has not
989/// exited yet, and a second read beside it is how memory runs out.
990const ANALYSIS_READ_WAITS: &str = "A cancelled run is still finishing; try again shortly";
991
992/// How long a cancelled run that stops within a batch may take before the screen
993/// says it is still going: time for a batch to finish, and no longer.
994const CANCEL_GRACE: std::time::Duration = std::time::Duration::from_secs(1);
995
996pub struct App {
997    pub data_table_state: Option<DataTableState>,
998    /// The footer counter of the dataset on screen, which its pass behind the open
999    /// reports to. Handed over by the load that installed it; a load in flight counts on
1000    /// its own until then. See [`Self::footer_progress`].
1001    footer_progress: Arc<crate::schema_union::FooterProgress>,
1002    /// The count as it stood when this frame began, or `None` if no pass was running.
1003    ///
1004    /// Taken once because the pass is running on other threads while the frame is
1005    /// drawn. The loading body and the control bar are painted a millisecond apart,
1006    /// and when each read the counter for itself they printed different numbers for
1007    /// one wait — and the bar could print a phase's flat percentage beside a count
1008    /// that had finished between the two reads.
1009    footers_this_frame: Option<(usize, usize)>,
1010    /// The objects a listing had found when this frame began, for the same reason.
1011    listed_this_frame: Option<usize>,
1012    /// Network roots currently being listed off-thread, so a probe is not started
1013    /// twice. Entries are never removed for a root that never answers — that thread
1014    /// is unreclaimable, and retrying it would only block another one.
1015    home_probes_inflight: Vec<PathBuf>,
1016    /// The stop flag of each cloud listing out, by place: leaving the place sets it, and
1017    /// the listing ends before its next page.
1018    home_listing_cancels: HashMap<PathBuf, Arc<std::sync::atomic::AtomicBool>>,
1019    /// The listing out for the names a filter asked of a cut-short cloud directory:
1020    /// where, the name prefix, and its stop flag.
1021    home_narrowing: Option<(PathBuf, String, Arc<std::sync::atomic::AtomicBool>)>,
1022    /// True once cloud discovery has been started. Enumeration costs a request per
1023    /// provider, so it happens once and its result is kept for the session.
1024    #[cfg(feature = "cloud")]
1025    cloud_discovery_started: bool,
1026    /// True while a recursive search below the working directory is out. One at a
1027    /// time: the walk is bounded, and a second one would only compete for the disk.
1028    home_search_inflight: bool,
1029    /// The home generation the walk out was started in. Its batches and its end are its
1030    /// own, whatever refreshes the listing meanwhile; the root decides whether they
1031    /// still describe where the user is.
1032    home_search_generation: u64,
1033    /// Set while the confirmation modal is asking about forgetting every recent.
1034    pending_clear_recents: bool,
1035    /// The checked link the confirmation modal is asking about opening.
1036    pending_link: Option<String>,
1037    /// Whether a browser opened here opens in front of the user: `o` on a
1038    /// documentation link is offered only then (`link_open::local_desktop`).
1039    pub local_desktop: bool,
1040    /// The place whose recents the confirmation modal is asking about forgetting.
1041    pending_forget_place: Option<PathBuf>,
1042    /// Why the last open failed, shown on the home screen when the error is dismissed
1043    /// and there is nothing to fall back to.
1044    last_load_error: Option<String>,
1045    /// Schema reads currently out, so the same one is not requested every frame.
1046    home_schema_inflight: Vec<PathBuf>,
1047    /// Invalidates listings and measurements from a request the user has moved past.
1048    home_generation: u64,
1049    /// Home screen state. Rebuilt from the filesystem whenever home is entered;
1050    /// nothing here is persisted beyond the recents list.
1051    pub home: home::HomeState,
1052    /// Schema previews, memoised for the session only. Persisting these would be a
1053    /// catalogue by another name, and it would go stale.
1054    home_schema_cache: HashMap<PathBuf, Option<discover::SchemaPreview>>,
1055    /// The home screen's `ROWS` previews, and the dataset the newest one built.
1056    pub home_previews: crate::home_preview::Previews,
1057    /// The reads of data started this session, by kind.
1058    pub reads: crate::home_preview::ReadCounts,
1059    /// The dataset whose downloaded shape is kept already. See
1060    /// [`Self::remember_a_downloads_shape`].
1061    shape_remembered: Option<u64>,
1062    path: Option<PathBuf>,
1063    original_file_format: Option<ExportFormat>, // Track original file format for default export
1064    original_file_delimiter: Option<u8>, // Track original file delimiter for CSV export default
1065    /// What `-` reads in place of standard input: a test's pipe.
1066    stdin_reader: Option<Box<dyn std::io::Read + Send>>,
1067    /// Where `--tee -` passes the stream on: standard output as the process got it.
1068    stdout_pass: Option<Box<dyn std::io::Write + Send>>,
1069    /// The follow mark as last drawn, so its clock redraws only when it changes.
1070    follow_drawn: Option<crate::render::footer::FollowMark>,
1071    /// Leaving was asked about while recording: what the user was doing.
1072    pending_leave: Option<Leaving>,
1073    /// A recording kept going after the user went home or quit, until its stream ends.
1074    recording_on: Option<Arc<crate::follow::SpoolHandle>>,
1075    /// A recording's end has been said: once, in the bar or the error dialog.
1076    recording_end_said: bool,
1077    events: Sender<AppEvent>,
1078    debug: DebugState,
1079    pub info_modal: InfoModal,
1080    /// What the Info panel's read found about the open file, and the
1081    /// `dataset_generation` it belongs to. Asked for when the panel opens, read on a
1082    /// worker ([`Job::FileFacts`], whose record says it is reading), and kept for the
1083    /// dataset however the read ended, so neither drawing nor reopening reads again.
1084    file_facts: Option<(u64, FileFacts)>,
1085    /// What the dataset's columns mean, when a catalog that lists it says.
1086    pub codebook: Option<std::sync::Arc<codebook::Codebook>>,
1087    /// The catalog entry the open dataset is, or is inside, and its catalog's label:
1088    /// what Info's Documentation tab shows.
1089    pub catalog_entry: Option<(String, std::sync::Arc<catalog::Dataset>)>,
1090    /// The Documentation view, full screen over home (Ctrl+E).
1091    pub documentation: widgets::documentation::DocState,
1092    /// The same page for the open dataset, on Info's Documentation tab.
1093    pub info_documentation: widgets::documentation::DocState,
1094    /// The directories Ctrl+D kept in the cache before 0.4.0 have been moved into
1095    /// `catalog.toml`, or there were none.
1096    remembered_moved: bool,
1097    /// Send a HEAD for the HTTP(S) file under the cursor on home, to show its size.
1098    /// Off under `cargo test`, which never reaches the network unless a test asks.
1099    pub head_web_rows: bool,
1100    // One input per command line language, each with its own history. The history
1101    // ids ("query", "sql") name files already on disk; they stay as they are so no
1102    // history is lost or read as another language's.
1103    query_input: TextInput, // q, history id "query"
1104    sql_input: TextInput,   // SQL, history id "sql"
1105    /// The find prompt (`/`) and the find `n` and `N` repeat; history id "find".
1106    pub find: find::Find,
1107    /// The column cursor moved last: the footer offers the column's keys.
1108    column_hints: bool,
1109    pub input_mode: InputMode,
1110    input_type: Option<InputType>,
1111    query_mode: QueryMode,
1112    /// The language Ctrl+T last chose, which the command line opens on until a query
1113    /// in effect says otherwise.
1114    query_mode_chosen: Option<QueryMode>,
1115    /// The command line holds the query in effect, selected and untouched: Ctrl+T
1116    /// carries it selected, so typing still replaces it.
1117    query_text_restored: bool,
1118    /// The columns of `df`, for the command line's list and completion. Taken from
1119    /// the schema when it opens.
1120    sql_columns: Vec<(String, DataType)>,
1121    /// A Tab completion in progress in the command line.
1122    sql_completion: Option<sql_assist::Cycle>,
1123    /// A query whose first collect is running, and the view to go back to if it
1124    /// fails. From the prompt, the prompt stays open until it is done.
1125    query_running: Option<QueryRun>,
1126    /// Why the last statement failed once it ran, shown under it in the prompt.
1127    query_run_error: Option<String>,
1128    /// Bumped when a running statement's failure lands in the prompt. Keys typed while
1129    /// it ran were not answers to it; see `EventPump`.
1130    inline_failures: u64,
1131    pub sort_filter_modal: SortFilterModal,
1132    pub pivot_melt_modal: PivotMeltModal,
1133    pub view_modal: ViewModal,
1134    /// Whether the open dataset was reached through the home screen. `q` pops
1135    /// the context: opened from home it returns there, launched straight onto
1136    /// a file it quits — the user's mental stack, not a mode.
1137    opened_from_home: bool,
1138    /// `--view NAME`, waiting for the dataset from the command line to land.
1139    /// Taken on the first install, so datasets opened later are not re-dressed.
1140    startup_view: Option<String>,
1141    pub analysis_modal: AnalysisModal,
1142    /// The Sample form over the table (`S`): the view's sample, the step under its
1143    /// query.
1144    pub sample_form: Option<sample_modal::SampleForm>,
1145    /// Where the memory available now is read from, which a sample is checked
1146    /// against. The system's, unless a test says otherwise.
1147    memory_probe: table_sample::MemoryProbe,
1148    /// How each random sample of a stream was drawn on this dataset, by what it was
1149    /// drawn from: drawn again, the same seed keeps the same rows whether or not the
1150    /// count has come in since.
1151    sample_paths: Vec<(String, table_sample::DrawPath)>,
1152    /// Reports, newest first, within [`QUALITY_MEMORY_BUDGET`].
1153    quality_cache: Vec<QualityCacheEntry>,
1154    /// See [`KeptQualitySample`]. Newest first, within [`QUALITY_MEMORY_BUDGET`].
1155    quality_samples: Vec<KeptQualitySample>,
1156    /// Acquisitions the budget released, newest first: (dataset, view, sample).
1157    quality_released: Vec<(u64, u64, sampling::Sample)>,
1158    /// [`QUALITY_MEMORY_BUDGET`], smaller in a test that fills it.
1159    quality_memory_budget: usize,
1160    /// Local copies Data Quality's full scans read instead of a remote source, newest
1161    /// first, within `analysis.quality_local_copy`. Removed from disk when
1162    /// released, when the dataset is opened again or replaced, and at exit.
1163    quality_copies: Vec<RetainedCopy>,
1164    /// The dataset whose copy was released, so Setup says why Run fetches again.
1165    quality_copy_released: Option<u64>,
1166    /// The dataset whose copy did not read as its source: its full scans read the
1167    /// source, and Setup says why.
1168    quality_copy_unusable: Option<u64>,
1169    /// Free bytes in the cache directory, and when they were asked: Setup redraws
1170    /// often, and the answer only feeds a line of text until Run asks again.
1171    quality_copy_free: std::sync::Mutex<Option<(std::time::Instant, Option<u64>)>>,
1172    /// The table an analysis drill left behind: Data Quality's matching rows or the
1173    /// sample's, shown in its place until Esc brings it back.
1174    quality_evidence_return: Option<Box<DataTableState>>,
1175    pub(crate) quality_evidence_label: Option<String>,
1176    pub chart_modal: ChartModal,
1177    pub chart_export_modal: ChartExportModal,
1178    pub export_modal: ExportModal,
1179    pub copy_modal: copy_modal::CopyModal,
1180    pub inspector_modal: inspector_modal::InspectorModal,
1181    /// A value the inspector wrote for another program, for the run loop to open:
1182    /// it owns the terminal that a waiting program takes over.
1183    external_open: Option<external_open::ExternalOpen>,
1184    /// Where those values are written; removed when the app is.
1185    open_dir: Option<tempfile::TempDir>,
1186    /// The shown columns, narrowed by what is typed, while `g` is choosing one.
1187    pub go_to_column: crate::widgets::ui::PickerState,
1188    /// The Value Counts screen (`F`).
1189    pub value_counts: value_counts_modal::ValueCountsModal,
1190    /// The counts the export dialog writes, when it was opened from Value Counts.
1191    export_counts: Option<polars::prelude::DataFrame>,
1192    /// The specs `b` offers for the dataset on screen.
1193    pub format_picker: crate::widgets::ui::PickerState,
1194    /// The type picker, while it is open.
1195    pub retype: Option<retype_modal::RetypeModal>,
1196    /// The combine form, while it is open.
1197    pub combine: Option<retype_modal::CombineModal>,
1198    /// The type picker or the combine form go back to the Info panel, not the table.
1199    pub(crate) retype_from_info: bool,
1200    /// The tables `T` offers: the picker's lines, and what each opens.
1201    pub table_picker: crate::widgets::ui::PickerState,
1202    pub table_choices: Option<table_switch::Tables>,
1203    /// The hex view (`InputMode::Hex`), kept while it is up.
1204    pub hex: Option<hex_view::HexView>,
1205    /// Bumped per hex view opened, so a find's answer for another is dropped.
1206    hex_serial: u64,
1207    /// Where copies go. Built at the first copy and kept for the run: on
1208    /// Wayland and X11 the clipboard offer dies with the process that owns it,
1209    /// so this handle must live as long as the copy should.
1210    clipboard: Option<Box<dyn clipboard::Destination>>,
1211    /// A table-scope copy waiting on the size confirmation.
1212    pending_copy: Option<(clipboard::CopyFormat, bool)>,
1213    pub(crate) chart_cache: ChartCache,
1214    /// The one chart preparation allowed to run at a time. Render draws only what is in
1215    /// `chart_cache`; this drives the throbber while it is current. Its result is
1216    /// installed only if the record is still current (not `stale`) and the dataset is
1217    /// the one it was computed from. Deliberately not
1218    /// `busy`: the sidebar stays live while the data is computed, and the newest
1219    /// selection is prepared once this one lands.
1220    chart_inflight: Option<ChartInflight>,
1221    /// The selection the chart last asked for, and, when it stepped the aggregate of
1222    /// the one before, until when it waits for the next step before it is prepared.
1223    chart_asked: Option<(ChartRequest, Option<std::time::Instant>)>,
1224    /// The result of the background chart preparation, like `pending_collect_result`:
1225    /// the data stays out of the event.
1226    pending_chart_result: ChartResultSlot,
1227    /// A chart export that asked for data still being prepared. `BackgroundChartReady`
1228    /// picks it up; `busy` stays set until then.
1229    chart_export_waiting: Option<ChartExportRequest>,
1230    error_modal: ErrorModal,
1231    flash: Option<Flash>,
1232    pub confirmation_modal: ConfirmationModal,
1233    /// An export waiting on the overwrite confirmation.
1234    pending_export: Option<ExportRequest>,
1235    /// The saved view `d` asked to delete, by id, while the confirmation is up.
1236    pending_delete_view: Option<String>,
1237    /// Delete on the Example datasets heading asked to hide them.
1238    pending_hide_examples: bool,
1239    pending_chart_export: Option<ChartExportRequest>,
1240    /// A Data Quality report export waiting on the overwrite confirmation.
1241    pending_quality_export: Option<(PathBuf, crate::quality_export::ReportFormat)>,
1242    /// The help overlay, over whatever screen it was opened at.
1243    help: help::Help,
1244    /// What the mouse can land on in the last frame, and the last click.
1245    pointer: pointer::Pointing,
1246    /// The menu a right click on a cell opened, while it is open.
1247    context_menu: Option<context_menu::ContextMenu>,
1248    cache: CacheManager,
1249    /// The recent and the shape an open writes, which the home listing waits on.
1250    cache_writes: CacheWrites,
1251    view_manager: Views,
1252    active_view_id: Option<String>, // ID of currently applied view
1253    /// An export under way, which the control bar reports.
1254    export_progress: Option<ExportProgress>,
1255    theme: Theme, // Color theme for UI rendering
1256    /// `a` is waiting on the confirmation to read every row.
1257    pending_read_all: bool,
1258    history_limit: usize, // History limit for all text inputs (from config.query.history_limit)
1259    table_cell_padding: u16, // Spaces between columns (from config.display.cell_padding)
1260    column_colors: bool, // When true, colorize table cells by column type (from config.display.column_colors)
1261    /// Second header row of column types. Starts from `display.type_row`; `D` flips it.
1262    dtype_row: bool,
1263    // Resolved display-time number formatting. `enabled` is flipped by the F key.
1264    number_format: NumberFormatSettings,
1265    runtime: tokio::runtime::Handle, // Tokio runtime handle for background tasks
1266    /// Every general background operation, and the generation their answers are judged
1267    /// by. See [`jobs`].
1268    jobs: Jobs,
1269    /// The open in flight, from the request to its first rows: its phase, what the
1270    /// loading screen says, and what it holds. See [`loading`]. Going home abandons it;
1271    /// an answer from an open it no longer holds is dropped.
1272    loading: loading::Loader,
1273    /// The paths the dataset on screen was opened from, with the options it installed
1274    /// with: what `H` opens again with its header turned the other way.
1275    opened: Option<(Vec<PathBuf>, OpenOptions)>,
1276    /// Where the last load-ahead was asked from. See [`App::load_ahead`].
1277    loaded_ahead_from: Option<(u64, usize, usize, usize)>,
1278    // `len_generation` of the in-flight background row-count, if any. Prevents re-spawning
1279    // the (potentially minutes-long) count on every scroll while it's still running.
1280    len_count_inflight: Option<u64>,
1281    /// The `len_generation` of a count `len_count_inflight` promises that has not
1282    /// started. A full count of a local frame competes with reading its first page for
1283    /// the disk and the Polars workers, and that page's rows can make it unnecessary,
1284    /// so it starts once a frame has painted them. See [`App::frame_painted`].
1285    count_after_paint: Option<u64>,
1286    /// Counts started, so a test can say none began before the page was painted.
1287    #[cfg(test)]
1288    counts_spawned: std::cell::Cell<usize>,
1289    /// Times an installed dataset's own first rows were asked for, so a test can say a
1290    /// view applied on open read them instead.
1291    #[cfg(test)]
1292    first_rows_asked: usize,
1293    // `len_generation` whose background row-count failed. While this matches the current
1294    // generation (and the count is still invalid) the row count is shown as "?" rather than a
1295    // misleading provisional total.
1296    len_count_failed: Option<u64>,
1297    /// End was pressed on a remote dataset before its rows were counted: go there when
1298    /// the count for this generation arrives, rather than to a guess.
1299    end_after_count: Option<u64>,
1300    /// What the pass behind a staged open found, for the frame that applies it: large
1301    /// enough to be worth keeping out of the event, and discarded if the dataset it
1302    /// belongs to has been replaced.
1303    pending_footers_result: std::sync::Arc<std::sync::Mutex<FootersReported>>,
1304    /// Bumped once per dataset put on screen, which the jobs' generation is not: a collect
1305    /// bumps that, and the pass reading the rest of a dataset's footers outlives
1306    /// several. It is what says whether the columns arriving belong to the dataset the
1307    /// user is looking at.
1308    dataset_generation: u64,
1309    /// End was pressed while a dataset was still reading its footers, which is where
1310    /// its end is coming from. Jump when they land — and only for that dataset, which
1311    /// is what the generation is for: a directory the user pressed End on and then walked
1312    /// away from must not move the view of the one they opened next. `end_after_count`
1313    /// alongside keys itself the same way, to `len_generation`.
1314    end_when_the_footers_land: Option<u64>,
1315    /// End was pressed while a text file's lines were still being indexed: jump when
1316    /// the last of them is, for that dataset alone.
1317    end_when_indexed: Option<u64>,
1318    /// Stops the indexing thread of the dataset on screen's lines.
1319    indexing_stop: Arc<std::sync::atomic::AtomicBool>,
1320    /// The lines being indexed, until they all are.
1321    indexing_lines: Option<Arc<crate::lines::Lines>>,
1322    /// The indexing waits while home is up.
1323    indexing_paused: bool,
1324    /// `:N` past the lines indexed so far, for that dataset: gone to once they all are.
1325    goto_when_indexed: Option<(u64, usize)>,
1326    /// The last count started: what it has read of the footers, and its stop (Esc).
1327    count_progress: Arc<crate::schema_union::FooterProgress>,
1328    /// The dataset (`dataset_generation`) an exact count was asked for (`c` in the
1329    /// Info panel), of more files than the count reads unasked.
1330    exact_count_asked: Option<u64>,
1331    /// `c` was pressed while a stopped count was still winding down: count again when
1332    /// its answer, for this `len_generation`, comes in.
1333    count_after_stop: Option<u64>,
1334    /// What a dataset's footers found while the user was looking at a query, a pivot or
1335    /// a drill-down rather than at the data. Held rather than applied, because widening
1336    /// the scan under a query takes the query's own columns away, and offered again the
1337    /// moment the view comes back to the dataset itself.
1338    footers_held: Option<(u64, crate::widgets::datatable::FootersFound)>,
1339    /// Fields a followed pipe's NDJSON brought after the open, held as footers are
1340    /// until the view is back on the data.
1341    followed_fields_held: Option<(u64, Vec<polars::prelude::Field>)>,
1342    /// A re-read the dataset is owed by a footer pass that came back empty-handed, held
1343    /// back because the collect it goes through would bump the generation out from
1344    /// under work already running. The pass that failed brings no columns to hold, so
1345    /// `footers_held` has nothing to say about it, and the dataset still needs the
1346    /// ordinary count the pass was going to save it — hence an errand of its own, tried
1347    /// again after every event until the work it would cancel is done.
1348    reread_owed: Option<u64>,
1349    /// Which home screen workers panic before their work starts, for tests of what a
1350    /// dying worker leaves behind. The jobs' own is [`Jobs::worker_dies`].
1351    #[cfg(test)]
1352    home_worker_dies: Option<HomeWorkerDies>,
1353    /// Reads the open file's facts in place of [`FileFacts::read`], for tests of a
1354    /// read that is slow or fails.
1355    #[cfg(test)]
1356    file_facts_reader: Option<FileFactsReader>,
1357    /// When true, show the throbber and defer keys (see [`App::handle`]); the main loop
1358    /// holds them until this clears.
1359    busy: bool,
1360    /// Bumped whenever the screen the user was typing at is replaced without a key of
1361    /// theirs asking for it: going home, abandoning a load. Keys held while busy carry
1362    /// the value they were typed under and are dropped if it has moved on.
1363    screen_generation: u64,
1364    /// Set by the main loop when it had to drop a key typed while busy, shown beside a
1365    /// status message while work is running. Cleared once the held keys have been
1366    /// replayed.
1367    input_dropped: bool,
1368    throbber_frame: u8, // Spinner frame index (0..3) for control bar
1369    /// Status text for the control bar, at the table view. Shown whether or not the app
1370    /// is busy: an End waiting on a remote row count parks without setting `busy`.
1371    status_message: Option<String>,
1372    analysis_computation: Option<AnalysisComputationState>,
1373    app_config: AppConfig,
1374    /// The terminal should be asked for its background before the next frame.
1375    background_query: bool,
1376    /// The format specs on the search path, read when the app was built.
1377    formats: Arc<crate::formats::Registry>,
1378}
1379
1380impl App {
1381    /// The finding under the cursor's rows: from the rows the run kept, at once, or
1382    /// staged as a read that says what it reads and waits for Enter. A finding with
1383    /// no rows to show opens nothing.
1384    fn open_quality_evidence(&mut self) -> Option<AppEvent> {
1385        let (_, finding) = self.analysis_modal.selected_finding()?;
1386        let results = self.analysis_modal.data_quality_results.as_ref()?;
1387        let rows = finding.evidence(results).ok()?;
1388        let sampled = results.precision == data_quality::QualityPrecision::Sampled;
1389        let count = finding.evidence_count(results);
1390        let label = format!(
1391            "Data Quality / {} / {}",
1392            finding.title,
1393            quality_report::columns_label(&finding.columns, 40)
1394        );
1395        let what = format!(
1396            "{} {} {}",
1397            finding.title,
1398            glyphs::get().middot,
1399            quality_report::columns_label(&finding.columns, 40)
1400        );
1401        self.show_quality_rows(rows, label, sampled, what, count)
1402    }
1403
1404    /// The rows an interval's count under the cursor counted: from the rows the run
1405    /// kept, or staged as a read when it kept none. Nothing opens for a count of none.
1406    fn open_interval_evidence(&mut self) -> Option<AppEvent> {
1407        let schema = self.data_table_state.as_ref().map(|state| state.schema());
1408        let (predicate, label, count) = self
1409            .analysis_modal
1410            .interval_evidence(schema.map(|schema| schema.as_ref()))?;
1411        let sampled = self
1412            .analysis_modal
1413            .data_quality_results
1414            .as_ref()
1415            .is_some_and(|results| results.precision == data_quality::QualityPrecision::Sampled);
1416        let what = label
1417            .trim_start_matches("Data Quality / ")
1418            .replace(" / ", &format!(" {} ", glyphs::get().middot));
1419        self.show_quality_rows(
1420            quality_report::EvidenceRows::Matching(predicate),
1421            label,
1422            sampled,
1423            what,
1424            Some(count),
1425        )
1426    }
1427
1428    /// The rows the report on screen measured, while they are kept: a sampled run's
1429    /// rows, same dataset, view and sample. `None` after a full scan, which keeps
1430    /// none, and once they are released.
1431    pub(crate) fn quality_rows_kept(&self) -> Option<std::sync::Arc<data_quality::QualitySample>> {
1432        self.analysis_modal.data_quality_results.as_ref()?;
1433        let plan = self.analysis_modal.quality_result_plan();
1434        if plan.compute != data_quality::QualityCompute::Sample {
1435            return None;
1436        }
1437        self.kept_quality_sample(&plan.sample())
1438    }
1439
1440    /// Open rows a Data Quality result counted. Kept rows are cut in memory and
1441    /// shown; anything else would read the source, so it is staged with what it
1442    /// reads, and only Enter on that reads.
1443    fn show_quality_rows(
1444        &mut self,
1445        rows: quality_report::EvidenceRows,
1446        label: String,
1447        sampled: bool,
1448        what: String,
1449        count: Option<usize>,
1450    ) -> Option<AppEvent> {
1451        let plan = self.analysis_modal.quality_result_plan().clone();
1452        let by_files = matches!(rows, quality_report::EvidenceRows::Files(_));
1453        let label = if sampled {
1454            format!("{label} / sampled")
1455        } else {
1456            label
1457        };
1458        if !by_files && self.quality_rows_kept().is_some() {
1459            return self.read_sample_rows(plan.sample(), Some((rows, label)));
1460        }
1461        let state = self.data_table_state.as_ref()?;
1462        let g = glyphs::get();
1463        let rows_label = |rows: usize| {
1464            format!(
1465                "{} {}",
1466                numfmt::group_chrome(rows),
1467                if rows == 1 { "row" } else { "rows" }
1468            )
1469        };
1470        let scope = match &rows {
1471            quality_report::EvidenceRows::Files(files) => files.clone(),
1472            _ => plan.scope.clone(),
1473        };
1474        // A sample as large as the scope reads every row too, but it was not a full
1475        // scan: its rows were kept, and since released.
1476        let not_kept = if plan.compute == data_quality::QualityCompute::Full {
1477            "a full scan keeps no rows"
1478        } else {
1479            "the rows read are no longer kept"
1480        };
1481        let (why, reads) = match &rows {
1482            quality_report::EvidenceRows::Files(data_quality::QualityScope::SourceFiles(files)) => {
1483                (
1484                    "their rows are in the files, not the report".to_string(),
1485                    format!(
1486                        "the {} named {}",
1487                        files.len(),
1488                        if files.len() == 1 { "file" } else { "files" }
1489                    ),
1490                )
1491            }
1492            _ if sampled => (
1493                "the sampled rows are no longer kept".to_string(),
1494                format!(
1495                    "the sample again: {} {} {}",
1496                    widgets::data_quality::compute_label(&plan),
1497                    g.middot,
1498                    widgets::data_quality::planned_read_label(state, &plan)
1499                ),
1500            ),
1501            quality_report::EvidenceRows::Duplicates => (
1502                not_kept.to_string(),
1503                format!(
1504                    "every row of {}, once {} {}",
1505                    plan.scope.label(),
1506                    g.middot,
1507                    widgets::data_quality::scope_read_label(state, &plan)
1508                ),
1509            ),
1510            // The table counts what matches, which reads every row; then it reads the
1511            // rows it shows.
1512            _ => (
1513                not_kept.to_string(),
1514                format!(
1515                    "every row of {} to count them, then the rows on screen {} {}",
1516                    plan.scope.label(),
1517                    g.middot,
1518                    widgets::data_quality::scope_read_label(state, &plan)
1519                ),
1520            ),
1521        };
1522        let shows = match (&rows, count) {
1523            (quality_report::EvidenceRows::Duplicates, Some(count)) => {
1524                format!("{}, copies together", rows_label(count))
1525            }
1526            (_, Some(count)) => rows_label(count),
1527            (_, None) => "the rows that match".to_string(),
1528        };
1529        let source = if state.is_remote_source() {
1530            "remote, read only"
1531        } else {
1532            "local, read only"
1533        };
1534        self.analysis_modal.data_quality_evidence_read = Some(analysis_modal::EvidenceRead {
1535            summary: vec![
1536                ("Rows", what),
1537                ("Why", why),
1538                ("Reads", reads),
1539                ("Shows", shows),
1540                ("Source", source.to_string()),
1541            ],
1542            sample: (sampled && !by_files).then(|| plan.sample()),
1543            scope,
1544            rows,
1545            label,
1546        });
1547        None
1548    }
1549
1550    /// Enter on a staged read: read the rows it named, as it said.
1551    fn confirm_evidence_read(&mut self) -> Option<AppEvent> {
1552        // Beside a cancelled run still reading, the read stays staged for later.
1553        let staged = self.analysis_modal.data_quality_evidence_read.as_ref()?;
1554        let kept = staged
1555            .sample
1556            .as_ref()
1557            .is_some_and(|sample| self.kept_quality_sample(sample).is_some());
1558        if !kept && self.read_waits_for_cancelled() {
1559            return None;
1560        }
1561        let read = self.analysis_modal.data_quality_evidence_read.take()?;
1562        if let Some(sample) = read.sample {
1563            return self.read_sample_rows(sample, Some((read.rows, read.label)));
1564        }
1565        let predicate = match read.rows {
1566            quality_report::EvidenceRows::Matching(predicate) => predicate,
1567            // A column its file never had, or holds in a type the scan cannot read,
1568            // has no value to filter on: its rows are the ones those files hold.
1569            quality_report::EvidenceRows::Files(_) => polars::prelude::lit(true),
1570            quality_report::EvidenceRows::Duplicates => {
1571                return self.read_duplicate_rows(read.scope, read.label);
1572            }
1573        };
1574        self.open_quality_scope_rows(&read.scope, predicate, read.label)
1575    }
1576
1577    /// Every row of `scope` that repeats, read in one pass off the UI thread and
1578    /// shown as a table: what a full scan's duplicate finding opens once asked to.
1579    fn read_duplicate_rows(
1580        &mut self,
1581        scope: data_quality::QualityScope,
1582        label: String,
1583    ) -> Option<AppEvent> {
1584        let state = self.data_table_state.as_ref()?;
1585        let (lf, schema) = match state.quality_scope_frame(&scope) {
1586            Ok(frame) => frame,
1587            Err(error) => {
1588                self.error_modal
1589                    .show(format!("Cannot open matching rows: {error}"));
1590                return None;
1591            }
1592        };
1593        let keys = schema.iter_names().cloned().collect::<Vec<_>>();
1594        // Binary values as the run grouped them: one stub for all, so they never
1595        // split a group the check counted as copies.
1596        let columns = schema
1597            .iter()
1598            .map(|(name, dtype)| {
1599                if matches!(dtype, polars::prelude::DataType::Binary) {
1600                    polars::prelude::lit(widgets::datatable::binary_stub()).alias(name.clone())
1601                } else {
1602                    polars::prelude::col(name.clone())
1603                }
1604            })
1605            .collect::<Vec<_>>();
1606        let streaming = self.app_config.performance.streaming;
1607        self.analysis_modal.computing = Some(AnalysisProgress::new("Reading the rows that repeat"));
1608        self.spawn_job(
1609            Job::SampleRows,
1610            Some("Reading the rows that repeat..."),
1611            move |_| {
1612                let df = data_quality::duplicate_rows(lf.select(columns), &keys, streaming)
1613                    .map_err(|error| format!("{error}"))?;
1614                Ok(Answer::Sample { df, label })
1615            },
1616        );
1617        None
1618    }
1619
1620    /// The rows of `scope` matching `predicate`, in the table viewer in place of the
1621    /// table; Esc brings the table and the report back.
1622    fn open_quality_scope_rows(
1623        &mut self,
1624        scope: &data_quality::QualityScope,
1625        predicate: polars::prelude::Expr,
1626        label: String,
1627    ) -> Option<AppEvent> {
1628        let state = self.data_table_state.as_ref()?;
1629        let view = match state.quality_evidence_view(scope, predicate) {
1630            Ok(view) => view,
1631            Err(error) => {
1632                self.error_modal
1633                    .show(format!("Cannot open matching rows: {error}"));
1634                return None;
1635            }
1636        };
1637        if let Some(original) = self.data_table_state.replace(view) {
1638            self.quality_evidence_return = Some(Box::new(original));
1639            self.quality_evidence_label = Some(label);
1640            self.analysis_modal.active = false;
1641            self.forget_the_rows_read();
1642            self.spawn_async_collect("Loading matching rows...");
1643        }
1644        None
1645    }
1646
1647    fn return_from_quality_evidence(&mut self, reopen_analysis: bool) -> bool {
1648        let Some(original) = self.quality_evidence_return.take() else {
1649            return false;
1650        };
1651        self.jobs.advance();
1652        self.len_count_inflight = None;
1653        self.data_table_state = Some(*original);
1654        self.quality_evidence_label = None;
1655        self.analysis_modal.active = reopen_analysis;
1656        self.busy = false;
1657        self.status_message = None;
1658        true
1659    }
1660
1661    fn restore_recent_quality_plan(&mut self) {
1662        let Some(view_generation) = self
1663            .data_table_state
1664            .as_ref()
1665            .map(DataTableState::len_generation)
1666        else {
1667            return;
1668        };
1669        if self.analysis_modal.data_quality_plan != data_quality::DataQualityPlan::default() {
1670            return;
1671        }
1672        if let Some(cached) = self.quality_cache.iter().find(|entry| {
1673            entry.dataset_generation == self.dataset_generation
1674                && entry.view_generation == view_generation
1675        }) {
1676            self.analysis_modal.data_quality_plan = cached.plan.clone();
1677        }
1678    }
1679
1680    /// What the data offers Setup's choices. Read from the schema and the rows
1681    /// already on screen: nothing here reads the source.
1682    fn quality_plan_context(&self) -> analysis_modal::PlanContext {
1683        let Some(state) = self.data_table_state.as_ref() else {
1684            return analysis_modal::PlanContext::default();
1685        };
1686        let plan = &self.analysis_modal.data_quality_plan;
1687        let scope = &plan.scope;
1688        let schema = state.schema();
1689        let mut partitions = state.partition_columns().unwrap_or_default().to_vec();
1690        // A directory whose files agree opens as one scan and names no partition
1691        // columns; its directory names still do.
1692        if partitions.is_empty()
1693            && let Some(dir) = self.path.as_ref().filter(|path| path.is_dir())
1694        {
1695            partitions = DataTableState::discover_hive_partition_columns(dir)
1696                .into_iter()
1697                .filter(|column| schema.get(column).is_some())
1698                .collect();
1699        }
1700        // Date and time columns, then text read as time: a window can split by either.
1701        let mut time_columns: Vec<(String, bool)> = state
1702            .quality_temporal_columns(scope)
1703            .into_iter()
1704            .map(|column| {
1705                let has_time =
1706                    !matches!(schema.get(&column), Some(polars::prelude::DataType::Date));
1707                (column, has_time)
1708            })
1709            .collect();
1710        for format in &plan.time_formats {
1711            if !time_columns
1712                .iter()
1713                .any(|(column, _)| *column == format.column)
1714            {
1715                time_columns.push((
1716                    format.column.clone(),
1717                    format.kind == data_quality::TimeKind::Datetime,
1718                ));
1719            }
1720        }
1721        analysis_modal::PlanContext {
1722            partitions,
1723            time_columns,
1724            files: state.quality_source_file_count() > 1,
1725            text_columns: state
1726                .quality_text_columns(scope)
1727                .into_iter()
1728                .map(|column| {
1729                    let examples = state.buffered_values(&column, 3);
1730                    (column, examples)
1731                })
1732                .collect(),
1733        }
1734    }
1735
1736    /// The columns a time role can be given: date and time columns, then text,
1737    /// which a role reads through its Text as time format.
1738    pub(crate) fn quality_time_candidates(&self) -> Vec<String> {
1739        let Some(state) = self.data_table_state.as_ref() else {
1740            return Vec::new();
1741        };
1742        let scope = &self.analysis_modal.data_quality_plan.scope;
1743        let mut columns = state.quality_temporal_columns(scope);
1744        columns.extend(state.quality_text_columns(scope));
1745        columns
1746    }
1747
1748    /// The columns Column intent lists: the draft's scope's, from the schema.
1749    pub(crate) fn quality_intent_columns(&self) -> Vec<(String, polars::prelude::DataType)> {
1750        self.data_table_state
1751            .as_ref()
1752            .map(|state| {
1753                crate::widgets::quality_intent::intent_columns(
1754                    state.quality_schema(&self.analysis_modal.data_quality_plan.scope),
1755                )
1756            })
1757            .unwrap_or_default()
1758    }
1759
1760    /// Open the intent form on the column under the cursor of the Column intent list.
1761    fn open_intent_form(&mut self) {
1762        let columns = self.quality_intent_columns();
1763        let modal = &mut self.analysis_modal;
1764        let Some((column, dtype)) = columns.get(modal.data_quality_plan_field) else {
1765            return;
1766        };
1767        let plan = &modal.data_quality_plan;
1768        modal.data_quality_intent_form = Some(intent_modal::IntentForm::new(
1769            column,
1770            dtype.clone(),
1771            plan.time_format(column).cloned(),
1772            &plan.intent,
1773            &self.theme,
1774        ));
1775    }
1776
1777    /// Keys while the intent form is open: Tab and ↑↓ walk its rows, Space and ←→
1778    /// change a choice, text fields type, Enter stages the declaration in Setup's
1779    /// draft and Esc drops the form's edits. Nothing here reads.
1780    fn intent_form_key(&mut self, event: &KeyEvent) {
1781        let modal = &mut self.analysis_modal;
1782        let Some(form) = modal.data_quality_intent_form.as_mut() else {
1783            return;
1784        };
1785        match form::key(form, event) {
1786            FormKey::Cancel => modal.data_quality_intent_form = None,
1787            FormKey::Submit => match form.apply(&mut modal.data_quality_plan.intent) {
1788                Ok(()) => modal.data_quality_intent_form = None,
1789                Err(error) => form.error = Some(error),
1790            },
1791            FormKey::Act(_) => form.adjust(true),
1792            FormKey::Step(_, delta) => form.adjust(delta > 0),
1793            FormKey::Text(_) => {
1794                if let Some(input) = form.input_mut() {
1795                    let _ = input.handle_key(event, None);
1796                }
1797                form.error = None;
1798            }
1799            FormKey::Moved | FormKey::Other => {}
1800        }
1801    }
1802
1803    /// What a Data Quality run reads from, as far as the app knows without reading:
1804    /// where it was opened from, its files, and what the view does to the rows when
1805    /// the scope is the view.
1806    fn quality_source_identity(
1807        &self,
1808        state: &DataTableState,
1809        scope: &data_quality::QualityScope,
1810    ) -> crate::quality_export::SourceIdentity {
1811        let format = self
1812            .original_file_format
1813            .map(|format| format.as_str().to_string())
1814            .or_else(|| {
1815                self.path
1816                    .as_ref()
1817                    .and_then(|path| path.extension())
1818                    .and_then(|extension| extension.to_str())
1819                    .map(str::to_string)
1820            });
1821        let mut view = Vec::new();
1822        if !scope.uses_source() {
1823            if !state.get_active_query().is_empty() {
1824                view.push(format!("query: {}", state.get_active_query()));
1825            }
1826            if !state.get_active_sql_query().is_empty() {
1827                view.push(format!("SQL: {}", state.get_active_sql_query()));
1828            }
1829            if !state.get_active_fuzzy_query().is_empty() {
1830                view.push(format!("text: {}", state.get_active_fuzzy_query()));
1831            }
1832            for (index, filter) in state.view_filters().iter().enumerate() {
1833                let join = if index == 0 {
1834                    String::new()
1835                } else {
1836                    format!("{} ", filter.logical_op.as_str())
1837                };
1838                view.push(format!(
1839                    "filter: {join}{} {} {}",
1840                    filter.column,
1841                    filter.operator.as_str(),
1842                    filter.value
1843                ));
1844            }
1845            if state.reshape_source().is_some() {
1846                view.push("reshaped: pivot or melt".to_string());
1847            }
1848        }
1849        let remote = state.is_remote_source();
1850        // A local path made whole, so the report names the file wherever it is read;
1851        // no file system access.
1852        let piped = self.reads_stdin();
1853        let location = self.path.as_ref().map(|path| {
1854            match std::path::absolute(path).ok().filter(|_| !remote && !piped) {
1855                Some(path) => path.display().to_string(),
1856                None => path.display().to_string(),
1857            }
1858        });
1859        crate::quality_export::SourceIdentity {
1860            location,
1861            remote,
1862            format,
1863            view,
1864            ..crate::quality_export::SourceIdentity::default()
1865        }
1866        .with_files(state.quality_source_file_names())
1867    }
1868
1869    /// The export dialog, on a name made from the dataset's.
1870    fn open_quality_export(&mut self) {
1871        let stem = self
1872            .path
1873            .as_ref()
1874            .and_then(|path| path.file_stem())
1875            .and_then(|stem| stem.to_str())
1876            .filter(|stem| !stem.is_empty())
1877            .unwrap_or("data")
1878            .to_string();
1879        self.analysis_modal.data_quality_export =
1880            Some(crate::quality_export::ExportForm::new(&stem, &self.theme));
1881    }
1882
1883    /// Keys while the export dialog is open: Tab moves between the path and the
1884    /// form, the arrows or Space change the form, Enter writes (asking first over a
1885    /// file that exists), Esc closes it.
1886    fn quality_export_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
1887        let form = self.analysis_modal.data_quality_export.as_mut()?;
1888        match event.code {
1889            KeyCode::Esc => self.analysis_modal.data_quality_export = None,
1890            KeyCode::Tab | KeyCode::BackTab | KeyCode::Up | KeyCode::Down => form.toggle_focus(),
1891            KeyCode::Left
1892            | KeyCode::Right
1893            | KeyCode::Char(' ')
1894            | KeyCode::Char('h')
1895            | KeyCode::Char('l')
1896                if form.on_format =>
1897            {
1898                form.cycle_format();
1899            }
1900            KeyCode::Enter => match form.target() {
1901                Err(error) => form.error = Some(error),
1902                Ok((path, format)) => {
1903                    if path.exists() {
1904                        let shown = path.display().to_string();
1905                        self.pending_quality_export = Some((path, format));
1906                        self.confirmation_modal.show_destructive(
1907                            format!("File already exists:\n{shown}\n\nOverwrite it?"),
1908                            "Overwrite",
1909                        );
1910                    } else {
1911                        // The dialog stays up while the report is written: a failed
1912                        // write says why on its status line, the path still there.
1913                        return Some(AppEvent::QualityReportExport(
1914                            path,
1915                            format,
1916                            Overwrite::Forbid,
1917                        ));
1918                    }
1919                }
1920            },
1921            _ if !form.on_format => {
1922                let _ = form.path.handle_key(event, None);
1923                form.error = None;
1924            }
1925            _ => {}
1926        }
1927        None
1928    }
1929
1930    /// Space on a Setup row: the Sample form, the role editor, or the row's choices.
1931    fn open_setup_row(&mut self) -> Option<AppEvent> {
1932        use analysis_modal::SetupRow;
1933        self.analysis_modal.data_quality_setup_note = None;
1934        match self.analysis_modal.setup_row() {
1935            SetupRow::Sample => self.open_quality_sample_form(),
1936            SetupRow::TimeRoles => {
1937                // With no date, time or text column there is no role to assign.
1938                if !self.quality_time_candidates().is_empty() {
1939                    self.analysis_modal.data_quality_plan_before_edit =
1940                        Some(self.analysis_modal.data_quality_plan.clone());
1941                    self.analysis_modal
1942                        .set_quality_page(data_quality::QualityPage::TimeRoles);
1943                    self.analysis_modal.data_quality_plan_field = 0;
1944                }
1945            }
1946            SetupRow::Intent => {
1947                if !self.quality_intent_columns().is_empty() {
1948                    self.analysis_modal.data_quality_plan_before_edit =
1949                        Some(self.analysis_modal.data_quality_plan.clone());
1950                    self.analysis_modal
1951                        .set_quality_page(data_quality::QualityPage::Intent);
1952                    self.analysis_modal.data_quality_plan_field = 0;
1953                }
1954            }
1955            SetupRow::Intervals => {
1956                // Two assigned roles make the first pair to choose.
1957                if !self
1958                    .analysis_modal
1959                    .data_quality_plan
1960                    .candidate_pairs()
1961                    .is_empty()
1962                {
1963                    self.analysis_modal.data_quality_plan_before_edit =
1964                        Some(self.analysis_modal.data_quality_plan.clone());
1965                    self.analysis_modal
1966                        .set_quality_page(data_quality::QualityPage::IntervalPairs);
1967                    self.analysis_modal.data_quality_plan_field = 0;
1968                }
1969            }
1970            SetupRow::Expected => {
1971                // Windows are what a gap is counted in; with no time-window grain
1972                // there is nothing to expect yet.
1973                if matches!(
1974                    self.analysis_modal.data_quality_plan.grain,
1975                    data_quality::QualityGrain::TimeWindows { .. }
1976                ) {
1977                    self.analysis_modal.data_quality_expected_form =
1978                        Some(analysis_modal::ExpectedForm::new(
1979                            &self.analysis_modal.data_quality_plan,
1980                            &self.theme,
1981                        ));
1982                    self.analysis_modal
1983                        .set_quality_page(data_quality::QualityPage::ExpectedWindows);
1984                }
1985            }
1986            row => {
1987                let context = self.quality_plan_context();
1988                self.analysis_modal.open_plan_picker(row, &context);
1989            }
1990        }
1991        None
1992    }
1993
1994    /// Keys in the Expected editor: ↑↓ the row, ←→ the cadence, typing in From and
1995    /// Before. Enter writes it into the draft, or says on its own line why it cannot;
1996    /// Esc leaves the draft as it was. Either way back to Setup's Expected row.
1997    fn expected_form_key(&mut self, event: &KeyEvent) {
1998        let every = match &self.analysis_modal.data_quality_plan.grain {
1999            data_quality::QualityGrain::TimeWindows { every, .. } => every.clone(),
2000            _ => String::new(),
2001        };
2002        let Some(form) = self.analysis_modal.data_quality_expected_form.as_mut() else {
2003            return;
2004        };
2005        match form::key(form, event) {
2006            FormKey::Cancel => {}
2007            FormKey::Submit => match form.expected() {
2008                Ok(expected) => self.analysis_modal.data_quality_plan.expected = expected,
2009                Err(problem) => {
2010                    form.error = Some(problem);
2011                    return;
2012                }
2013            },
2014            FormKey::Step(_, delta) => {
2015                form.cycle(&every, delta > 0);
2016                return;
2017            }
2018            FormKey::Text(_) => {
2019                if let Some(input) = form.input_mut() {
2020                    let _ = input.handle_key(event, None);
2021                    form.error = None;
2022                }
2023                return;
2024            }
2025            FormKey::Act(_) | FormKey::Moved | FormKey::Other => return,
2026        }
2027        self.analysis_modal.data_quality_expected_form = None;
2028        self.analysis_modal.data_quality_setup_note = None;
2029        self.analysis_modal
2030            .set_quality_page(data_quality::QualityPage::Setup);
2031        self.analysis_modal.data_quality_plan_field = analysis_modal::SetupRow::Expected.index();
2032    }
2033
2034    /// `w` on Trends: the next coarser grain, staged in Setup with the Grain row under
2035    /// the cursor, for segments the sample reached too thinly. Nothing runs until
2036    /// Enter, and Setup's Read says what that run reads; Esc puts the grain back.
2037    fn stage_coarser_grain(&mut self) {
2038        let Some(coarser) = self.analysis_modal.quality_result_plan().coarser_grain() else {
2039            return;
2040        };
2041        self.open_quality_setup();
2042        let plan = &mut self.analysis_modal.data_quality_plan;
2043        plan.grain = coarser;
2044        plan.baseline_segment = None;
2045        self.analysis_modal.data_quality_plan_field = analysis_modal::SetupRow::Grain.index();
2046    }
2047
2048    /// Enter in a Setup row's list: take the choice, and after a text column, ask
2049    /// for its format with the values on screen beside each one.
2050    fn choose_setup_picker(&mut self) {
2051        self.analysis_modal.data_quality_setup_note = None;
2052        if let Some(column) = self.analysis_modal.choose_plan_picker() {
2053            let examples = self
2054                .data_table_state
2055                .as_ref()
2056                .map(|state| state.buffered_values(&column, 3))
2057                .unwrap_or_default();
2058            self.analysis_modal.open_format_picker(&column, &examples);
2059        }
2060    }
2061
2062    /// The Setup setting the Data Quality page on screen lacks before it can show
2063    /// anything; Enter opens it, and the control bar says so.
2064    pub(crate) fn quality_page_setup(&self) -> Option<data_quality::QualitySetup> {
2065        let modal = &self.analysis_modal;
2066        data_quality::page_setup(
2067            modal.data_quality_page,
2068            modal.quality_result_plan(),
2069            modal.data_quality_results.as_ref(),
2070            self.has_quality_time_columns(),
2071        )
2072    }
2073
2074    /// Whether the plan's scope has a column it reads as time: a date or time
2075    /// column, or text given a format. What an empty Trends page points to.
2076    pub(crate) fn has_quality_time_columns(&self) -> bool {
2077        !self
2078            .analysis_modal
2079            .data_quality_plan
2080            .time_formats
2081            .is_empty()
2082            || self.data_table_state.as_ref().is_some_and(|state| {
2083                !state
2084                    .quality_temporal_columns(&self.analysis_modal.data_quality_plan.scope)
2085                    .is_empty()
2086            })
2087    }
2088
2089    /// Whether Data Quality's retained rows are the rows `plan` reads, so a run
2090    /// starts from them rather than from the source. They serve any grain: every
2091    /// column is kept, and where each row sat.
2092    pub(crate) fn quality_kept_serves(&self, plan: &data_quality::DataQualityPlan) -> bool {
2093        plan.compute == data_quality::QualityCompute::Sample
2094            && self.kept_quality_sample(&plan.sample()).is_some()
2095    }
2096
2097    /// Where a run of `plan` gets its exact segment totals: from the retained rows'
2098    /// counts, from the pass that reads a new sample, or from a read of their own.
2099    pub(crate) fn quality_segment_count(
2100        &self,
2101        plan: &data_quality::DataQualityPlan,
2102    ) -> data_quality::SegmentCount {
2103        if plan.compute != data_quality::QualityCompute::Sample {
2104            return data_quality::SegmentCount::NotNeeded;
2105        }
2106        match self.kept_quality_sample(&plan.sample()) {
2107            Some(kept) => kept.segment_count(plan),
2108            None => data_quality::fresh_segment_count(plan, self.quality_may_read_blocks(plan)),
2109        }
2110    }
2111
2112    /// Whether the rows `plan` reads were read this session and released to the
2113    /// memory budget, so a Run reads them again.
2114    pub(crate) fn quality_released(&self, plan: &data_quality::DataQualityPlan) -> bool {
2115        let Some(view_generation) = self
2116            .data_table_state
2117            .as_ref()
2118            .map(DataTableState::len_generation)
2119        else {
2120            return false;
2121        };
2122        let sample = plan.sample();
2123        plan.compute == data_quality::QualityCompute::Sample
2124            && self
2125                .quality_released
2126                .iter()
2127                .any(|(dataset, view, released)| {
2128                    *dataset == self.dataset_generation
2129                        && *view == view_generation
2130                        && *released == sample
2131                })
2132    }
2133
2134    /// Whether the dataset is one Parquet or IPC file, the one kind the sampler may
2135    /// read seeded runs of.
2136    fn quality_one_columnar_file(&self) -> bool {
2137        let Some(state) = self.data_table_state.as_ref() else {
2138            return false;
2139        };
2140        let columnar = matches!(
2141            self.original_file_format,
2142            Some(ExportFormat::Parquet | ExportFormat::Ipc)
2143        ) || self.path.as_ref().is_some_and(|path| {
2144            path.extension()
2145                .and_then(|extension| extension.to_str())
2146                .is_some_and(|extension| {
2147                    matches!(
2148                        extension.to_ascii_lowercase().as_str(),
2149                        "parquet" | "pq" | "arrow" | "arrows" | "ipc" | "feather"
2150                    )
2151                })
2152        });
2153        columnar && state.loaded_file_count() == 1
2154    }
2155
2156    /// Whether a random sample of `plan` reads seeded runs of one file rather than
2157    /// stream every row, as Setup's Read says. Told from the path and the view, since
2158    /// the sampler's own test needs the plan built. Yes only where the scan is read as
2159    /// loaded: the whole source whatever the view, or a view that picks no rows (a
2160    /// sort does not count: samples read the view unsorted). Where it is not sure,
2161    /// Setup names the longer read.
2162    pub(crate) fn quality_reads_blocks(&self, plan: &data_quality::DataQualityPlan) -> bool {
2163        let Some(state) = self.data_table_state.as_ref() else {
2164            return false;
2165        };
2166        self.quality_one_columnar_file()
2167            && match plan.scope {
2168                data_quality::QualityScope::WholeSource => true,
2169                data_quality::QualityScope::CurrentView => !state.changes_rows(),
2170                // Read in the order on screen, sort included.
2171                data_quality::QualityScope::FirstRows(_)
2172                | data_quality::QualityScope::ViewRows { .. } => {
2173                    state.source_file_count() == Some(1)
2174                }
2175                _ => false,
2176            }
2177    }
2178
2179    /// Whether a random sample of `plan` may read seeded runs: where
2180    /// [`Self::quality_reads_blocks`] is sure, and wherever the view may still read
2181    /// the scan as loaded, a query's included.
2182    ///
2183    /// Leans to yes: seeded runs see too few rows to count segments, so a yes is what
2184    /// makes Setup name a count pass, and a run that streams after all counts in its
2185    /// one pass and reads less than Setup said, never more.
2186    pub(crate) fn quality_may_read_blocks(&self, plan: &data_quality::DataQualityPlan) -> bool {
2187        let Some(state) = self.data_table_state.as_ref() else {
2188            return false;
2189        };
2190        self.quality_reads_blocks(plan)
2191            || (self.quality_one_columnar_file()
2192                && state.may_keep_scan_rows()
2193                && matches!(
2194                    plan.scope,
2195                    data_quality::QualityScope::CurrentView
2196                        | data_quality::QualityScope::FirstRows(_)
2197                        | data_quality::QualityScope::ViewRows { .. }
2198                ))
2199    }
2200
2201    /// Whether the session cache holds a report measuring what `plan` measures on
2202    /// this view: the windows it expects are checked against the report, not read.
2203    pub(crate) fn quality_cached(&self, plan: &data_quality::DataQualityPlan) -> bool {
2204        let Some(view_generation) = self
2205            .data_table_state
2206            .as_ref()
2207            .map(DataTableState::len_generation)
2208        else {
2209            return false;
2210        };
2211        self.quality_cache.iter().any(|entry| {
2212            entry.dataset_generation == self.dataset_generation
2213                && entry.view_generation == view_generation
2214                && entry.plan.same_measurement(plan)
2215        })
2216    }
2217
2218    /// Everything Data Quality's runs kept for reuse on this dataset, as `d` in Setup
2219    /// would release it: sampled rows in memory and a full scan's local copy on
2220    /// disk. `None` when there is neither.
2221    pub(crate) fn quality_kept_rows(&self) -> Option<widgets::data_quality::KeptRows> {
2222        let kept = self
2223            .quality_samples
2224            .iter()
2225            .filter(|kept| kept.dataset_generation == self.dataset_generation)
2226            .collect::<Vec<_>>();
2227        let copy_bytes = self
2228            .quality_copies
2229            .iter()
2230            .filter(|kept| kept.dataset_generation == self.dataset_generation)
2231            .map(|kept| kept.copy.bytes())
2232            .sum::<u64>();
2233        (!kept.is_empty() || copy_bytes > 0).then(|| widgets::data_quality::KeptRows {
2234            samples: kept.len(),
2235            rows: kept.iter().map(|kept| kept.rows.df().height()).sum(),
2236            bytes: kept.iter().map(|kept| kept.rows.estimated_bytes()).sum(),
2237            copy_bytes,
2238        })
2239    }
2240
2241    /// `d` in Setup: let go of every row runs kept, as the memory budget would, and
2242    /// the local copy a full scan fetched, whose files go from disk. A run that would
2243    /// have reused either reads again, and Setup's Read says so before Run. Reports
2244    /// stay: they are results, and showing one reads nothing.
2245    fn release_quality_rows(&mut self) {
2246        let Some(kept) = self.quality_kept_rows() else {
2247            self.flash_note("Nothing kept to release".to_string());
2248            return;
2249        };
2250        for released in std::mem::take(&mut self.quality_samples) {
2251            self.quality_released.retain(|(dataset, view, sample)| {
2252                !(*dataset == released.dataset_generation
2253                    && *view == released.view_generation
2254                    && *sample == released.sample)
2255            });
2256            self.quality_released.insert(
2257                0,
2258                (
2259                    released.dataset_generation,
2260                    released.view_generation,
2261                    released.sample,
2262                ),
2263            );
2264        }
2265        self.quality_released.truncate(QUALITY_RELEASED_REMEMBERED);
2266        // A run still reading the copy holds it until it ends; then the files go.
2267        let generation = self.dataset_generation;
2268        self.quality_copies
2269            .retain(|kept| kept.dataset_generation != generation);
2270        if kept.copy_bytes > 0 {
2271            self.quality_copy_released = Some(generation);
2272        }
2273        let rows = format!(
2274            "{} kept {} ({})",
2275            numfmt::group_chrome(kept.rows),
2276            if kept.rows == 1 { "row" } else { "rows" },
2277            widgets::info::format_bytes(kept.bytes as u64)
2278        );
2279        let copy = format!(
2280            "the local copy ({})",
2281            widgets::info::format_bytes(kept.copy_bytes)
2282        );
2283        self.flash_note(match (kept.samples > 0, kept.copy_bytes > 0) {
2284            (true, true) => format!("Released {rows} and {copy}; the next run reads again"),
2285            (false, true) => format!("Released {copy}; the next full scan fetches again"),
2286            _ => format!("Released {rows}; the next run reads again"),
2287        });
2288    }
2289
2290    /// Rows a sampled Data Quality run read, when they are the rows `sample` names
2291    /// now: same dataset, same view, same sample.
2292    fn kept_quality_sample(
2293        &self,
2294        sample: &sampling::Sample,
2295    ) -> Option<std::sync::Arc<data_quality::QualitySample>> {
2296        self.kept_quality_entry(sample)
2297            .map(|kept| kept.rows.clone())
2298    }
2299
2300    fn kept_quality_entry(&self, sample: &sampling::Sample) -> Option<&KeptQualitySample> {
2301        let view_generation = self.data_table_state.as_ref()?.len_generation();
2302        self.quality_samples.iter().find(|kept| {
2303            kept.dataset_generation == self.dataset_generation
2304                && kept.view_generation == view_generation
2305                && &kept.sample == sample
2306        })
2307    }
2308
2309    /// Keep what a run read, newest first, in place of any earlier copy of the same
2310    /// rows: a later run returns them with the counts it added.
2311    fn retain_quality_sample(&mut self, kept: &KeptQualitySample) {
2312        if kept.dataset_generation != self.dataset_generation {
2313            return;
2314        }
2315        self.quality_samples.retain(|entry| !entry.same_rows(kept));
2316        self.quality_released.retain(|(dataset, view, sample)| {
2317            !(*dataset == kept.dataset_generation
2318                && *view == kept.view_generation
2319                && *sample == kept.sample)
2320        });
2321        self.quality_samples.insert(0, kept.clone());
2322        self.trim_quality_memory();
2323    }
2324
2325    /// Hold Data Quality's reports and retained rows to [`QUALITY_MEMORY_BUDGET`].
2326    ///
2327    /// A report whose rows are still retained goes first: remaking it reads nothing.
2328    /// Then the oldest rows, whose next run reads them again, which Setup says. A
2329    /// report with no rows behind it, a full scan's, goes last: it is the dearest to
2330    /// remake. The newest report and the newest rows always stay, whatever their size,
2331    /// so a finished read is never thrown away to make room for itself.
2332    fn trim_quality_memory(&mut self) {
2333        loop {
2334            let used = self
2335                .quality_cache
2336                .iter()
2337                .map(|entry| entry.bytes)
2338                .sum::<usize>()
2339                + self
2340                    .quality_samples
2341                    .iter()
2342                    .map(|kept| kept.rows.estimated_bytes())
2343                    .sum::<usize>();
2344            if used <= self.quality_memory_budget {
2345                return;
2346            }
2347            let remakeable = self
2348                .quality_cache
2349                .iter()
2350                .enumerate()
2351                .skip(1)
2352                .rev()
2353                .find(|(_, entry)| {
2354                    entry.plan.compute == data_quality::QualityCompute::Sample
2355                        && self.quality_samples.iter().any(|kept| {
2356                            kept.dataset_generation == entry.dataset_generation
2357                                && kept.view_generation == entry.view_generation
2358                                && kept.sample == entry.plan.sample()
2359                        })
2360                })
2361                .map(|(index, _)| index);
2362            if let Some(index) = remakeable {
2363                self.quality_cache.remove(index);
2364            } else if self.quality_samples.len() > 1 {
2365                if let Some(released) = self.quality_samples.pop() {
2366                    self.quality_released.insert(
2367                        0,
2368                        (
2369                            released.dataset_generation,
2370                            released.view_generation,
2371                            released.sample,
2372                        ),
2373                    );
2374                    self.quality_released.truncate(QUALITY_RELEASED_REMEMBERED);
2375                }
2376            } else if self.quality_cache.len() > 1 {
2377                self.quality_cache.pop();
2378            } else {
2379                return;
2380            }
2381        }
2382    }
2383
2384    fn restore_cached_quality(&mut self) -> bool {
2385        let Some(view_generation) = self
2386            .data_table_state
2387            .as_ref()
2388            .map(DataTableState::len_generation)
2389        else {
2390            return false;
2391        };
2392        let plan = self.analysis_modal.data_quality_plan.clone();
2393        let Some(cached) = self.quality_cache.iter().find(|entry| {
2394            entry.dataset_generation == self.dataset_generation
2395                && entry.view_generation == view_generation
2396                && entry.plan.same_measurement(&plan)
2397        }) else {
2398            return false;
2399        };
2400        let mut results = cached.results.clone();
2401        if cached.plan != plan {
2402            // Compared as the plan compares, and kept under the windows it expects now.
2403            if cached.plan.compares_differently(&plan) {
2404                results.compare_segments(&plan);
2405            }
2406            self.cache_quality_result(&results, plan.clone());
2407        }
2408        self.analysis_modal.data_quality_results = Some(results);
2409        self.analysis_modal.data_quality_last_plan = Some(plan);
2410        self.analysis_modal.data_quality_from_cache = true;
2411        self.analysis_modal
2412            .set_quality_page(data_quality::QualityPage::Overview);
2413        true
2414    }
2415
2416    fn cache_quality_result(
2417        &mut self,
2418        results: &data_quality::DataQualityResults,
2419        plan: data_quality::DataQualityPlan,
2420    ) {
2421        let Some(view_generation) = self
2422            .data_table_state
2423            .as_ref()
2424            .map(DataTableState::len_generation)
2425        else {
2426            return;
2427        };
2428        // One report per measurement: a plan that only expects other windows
2429        // replaces it.
2430        self.quality_cache.retain(|entry| {
2431            !(entry.dataset_generation == self.dataset_generation
2432                && entry.view_generation == view_generation
2433                && entry.plan.same_measurement(&plan))
2434        });
2435        self.quality_cache.insert(
2436            0,
2437            QualityCacheEntry {
2438                dataset_generation: self.dataset_generation,
2439                view_generation,
2440                plan,
2441                bytes: results.estimated_bytes(),
2442                results: results.clone(),
2443            },
2444        );
2445        self.trim_quality_memory();
2446    }
2447
2448    /// The scope a full scan's passes read: `lf` over a local copy in place of its
2449    /// remote objects, fetched first when `job` says so. `kept` hears whether the
2450    /// copy stands in for the source: the copy to keep, or `None`, after which the
2451    /// dataset's full scans read the source. The copy comes back too, for the caller
2452    /// to hold while the passes read it.
2453    fn quality_scope_on_copy(
2454        lf: LazyFrame,
2455        job: QualityCopyJob,
2456        watch: &data_quality::QualityWatch,
2457        fetch: impl FnOnce(
2458            &[crate::local_copy::RemoteObject],
2459            &Path,
2460        ) -> Result<crate::local_copy::LocalCopy>,
2461        kept: impl FnOnce(Option<Arc<crate::local_copy::LocalCopy>>),
2462    ) -> Result<(LazyFrame, Option<Arc<crate::local_copy::LocalCopy>>)> {
2463        let (copy, fetched) = match job {
2464            QualityCopyJob::Source => return Ok((lf, None)),
2465            QualityCopyJob::Kept(copy) => (copy, false),
2466            QualityCopyJob::Fetch { objects, root } => {
2467                watch.stage(data_quality::QualityStage::CopyingSource, true, true)?;
2468                let copy = fetch(&objects, &root).map_err(|error| {
2469                    if watch.cancelled() {
2470                        color_eyre::eyre::eyre!(crate::sampling::CANCELLED)
2471                    } else {
2472                        error
2473                    }
2474                })?;
2475                (Arc::new(copy), true)
2476            }
2477        };
2478        // The copy must read as the source does, or the source is read as before.
2479        let local = copy.redirect(&lf).filter(|local| {
2480            let schemas = (local.clone().collect_schema(), lf.clone().collect_schema());
2481            matches!(schemas, (Ok(local), Ok(source)) if local == source)
2482        });
2483        let Some(local) = local else {
2484            log::warn!(target: "datui", "local copy does not read as the source; reading the source");
2485            kept(None);
2486            return Ok((lf, None));
2487        };
2488        if fetched {
2489            kept(Some(copy.clone()));
2490        }
2491        watch.use_copy(data_quality::CopyRead {
2492            bytes: copy.bytes(),
2493            objects: copy.objects(),
2494            fetched,
2495        });
2496        Ok((local, Some(copy)))
2497    }
2498
2499    /// Copy `objects` under `root`, each streamed from its store and written as it
2500    /// arrives; a cancel stops it at the next chunk and the partial copy is removed.
2501    #[cfg(feature = "cloud")]
2502    fn fetch_quality_copy(
2503        objects: &[crate::local_copy::RemoteObject],
2504        root: &Path,
2505        cloud: &crate::config::CloudConfig,
2506        runtime: &tokio::runtime::Handle,
2507        stop: &crate::sampling::ReadWatch,
2508    ) -> Result<crate::local_copy::LocalCopy> {
2509        use crate::download::StreamError;
2510        use object_store::ObjectStoreExt;
2511
2512        crate::local_copy::LocalCopy::fetch(root, objects, stop, |object, write| {
2513            let url = object.url.as_str();
2514            let (_, _, store) = Self::cloud_store_for(Path::new(url), cloud, runtime)?;
2515            let (_, key) = Self::cloud_bucket_and_key(url)?;
2516            let path = crate::cloud_browse::object_path(&key);
2517            let listed = object.etag.clone();
2518            let open = async move {
2519                let got = store.get(&path).await.map_err(|e| e.to_string())?;
2520                // Rewritten since it opened, perhaps at the same size: the copy would
2521                // not be the dataset on screen.
2522                if let (Some(listed), Some(fetched)) = (&listed, &got.meta.e_tag)
2523                    && !crate::local_copy::same_etag(listed, fetched)
2524                {
2525                    return Err("it changed since it opened. Open the dataset again".to_string());
2526                }
2527                Ok((got.into_stream(), None))
2528            };
2529            let watch = stop.clone();
2530            crate::download::stream_into(runtime, open, move || watch.stopped(), write)
2531                .map(drop)
2532                .map_err(|error| match error {
2533                    StreamError::Write(report) => report,
2534                    StreamError::Open(e) | StreamError::Read(e) => {
2535                        color_eyre::eyre::eyre!("Could not copy {url}: {e}")
2536                    }
2537                    StreamError::Short { expected, got } => color_eyre::eyre::eyre!(
2538                        "Could not copy {url}: it ended after {got} of {expected} bytes"
2539                    ),
2540                    StreamError::Cut => color_eyre::eyre::eyre!(crate::sampling::CANCELLED),
2541                })
2542        })
2543    }
2544
2545    /// Where Data Quality's local copies are written.
2546    fn quality_copies_root(&self) -> PathBuf {
2547        self.cache.cache_dir().join(crate::local_copy::COPIES_DIR)
2548    }
2549
2550    /// `analysis.quality_local_copy`, in bytes.
2551    fn quality_copy_limit(&self) -> u64 {
2552        self.app_config.analysis.quality_local_copy.bytes()
2553    }
2554
2555    /// Bytes on disk in the copies kept.
2556    pub fn quality_copy_bytes(&self) -> u64 {
2557        self.quality_copies
2558            .iter()
2559            .map(|kept| kept.copy.bytes())
2560            .sum()
2561    }
2562
2563    /// The copy this dataset's objects were fetched into this session, while kept.
2564    fn quality_copy_kept(&self) -> Option<&Arc<crate::local_copy::LocalCopy>> {
2565        let state = self.data_table_state.as_ref()?;
2566        self.quality_copies
2567            .iter()
2568            .find(|kept| {
2569                kept.dataset_generation == self.dataset_generation
2570                    && state.each_remote_object().is_some_and(|mut objects| {
2571                        objects.all(|object| {
2572                            object.is_some_and(|object| kept.copy.covers(&object.url))
2573                        })
2574                    })
2575            })
2576            .map(|kept| &kept.copy)
2577    }
2578
2579    /// Free bytes where copies are written, asked at most every few seconds.
2580    fn quality_copy_free_space(&self) -> Option<u64> {
2581        let root = self.quality_copies_root();
2582        let Ok(mut cached) = self.quality_copy_free.lock() else {
2583            return crate::local_copy::free_space(&root);
2584        };
2585        match *cached {
2586            Some((asked, free)) if asked.elapsed() < std::time::Duration::from_secs(5) => free,
2587            _ => {
2588                let free = crate::local_copy::free_space(&root);
2589                *cached = Some((std::time::Instant::now(), free));
2590                free
2591            }
2592        }
2593    }
2594
2595    /// How a run of `plan` gets its rows from a remote source, from what the open
2596    /// learned: no read, and no more than a stat of the cache directory.
2597    pub(crate) fn quality_copy_plan(
2598        &self,
2599        plan: &data_quality::DataQualityPlan,
2600    ) -> data_quality::CopyPlan {
2601        use data_quality::{CopyPlan, NoCopy};
2602        let Some(state) = self.data_table_state.as_ref() else {
2603            return CopyPlan::NotApplicable;
2604        };
2605        if plan.compute != data_quality::QualityCompute::Full || !state.is_remote_source() {
2606            return CopyPlan::NotApplicable;
2607        }
2608        if !state.quality_reads_whole_source(&plan.scope) {
2609            return CopyPlan::Passes(NoCopy::PartOfTheSource);
2610        }
2611        if let Some(copy) = self.quality_copy_kept() {
2612            return CopyPlan::Kept {
2613                bytes: copy.bytes(),
2614                objects: copy.objects(),
2615            };
2616        }
2617        let limit = self.quality_copy_limit();
2618        if limit == 0 {
2619            return CopyPlan::Passes(NoCopy::Off);
2620        }
2621        if self.quality_copy_unusable == Some(self.dataset_generation) {
2622            return CopyPlan::Passes(NoCopy::Unusable);
2623        }
2624        let Some((bytes, objects)) = state.remote_objects_size() else {
2625            return CopyPlan::Passes(NoCopy::SizeUnknown);
2626        };
2627        if bytes > limit {
2628            return CopyPlan::Passes(NoCopy::TooLarge { bytes, limit });
2629        }
2630        let free = self.quality_copy_free_space();
2631        if free.is_none_or(|free| bytes > free) {
2632            return CopyPlan::Passes(NoCopy::NoRoom { bytes, free });
2633        }
2634        CopyPlan::Fetch { bytes, objects }
2635    }
2636
2637    /// Whether this dataset's copy was released this session, so Run fetches again.
2638    pub(crate) fn quality_copy_released(&self) -> bool {
2639        self.quality_copy_released == Some(self.dataset_generation)
2640    }
2641
2642    /// Keep a copy a run fetched, newest first. Older copies go past the budget;
2643    /// the newest stays, so a finished fetch is never thrown away for itself. With
2644    /// none, the dataset's copy did not read as its source: any kept one goes too.
2645    fn retain_quality_copy(
2646        &mut self,
2647        dataset_generation: u64,
2648        copy: Option<Arc<crate::local_copy::LocalCopy>>,
2649    ) {
2650        if dataset_generation != self.dataset_generation {
2651            return;
2652        }
2653        let Some(copy) = copy else {
2654            self.quality_copies
2655                .retain(|kept| kept.dataset_generation != dataset_generation);
2656            self.quality_copy_unusable = Some(dataset_generation);
2657            return;
2658        };
2659        self.quality_copies.insert(
2660            0,
2661            RetainedCopy {
2662                dataset_generation,
2663                copy,
2664            },
2665        );
2666        self.quality_copy_released = None;
2667        let limit = self.quality_copy_limit();
2668        while self.quality_copies.len() > 1 && self.quality_copy_bytes() > limit {
2669            self.quality_copies.pop();
2670        }
2671    }
2672
2673    /// Whether keys wait: a job the user is waiting on is running or owed, an errand
2674    /// is between its phases, or an open is on its way to its dataset.
2675    pub fn is_busy(&self) -> bool {
2676        self.busy || self.jobs.holds_keys() || self.loading.waits()
2677    }
2678
2679    /// The generation background answers are judged by. Advanced each time work starts
2680    /// that replaces what is in flight.
2681    pub fn task_generation(&self) -> u64 {
2682        self.jobs.generation()
2683    }
2684
2685    /// Whether the job `ticket` names is still running and its answer still wanted.
2686    pub fn job_is_current(&self, ticket: Ticket) -> bool {
2687        self.jobs.is_current(ticket)
2688    }
2689
2690    /// Keep recents, histories and measurements in `cache` from now on. For a test that
2691    /// reads its store back: every test in a process shares one, and fifty opens
2692    /// elsewhere push its entries out of the capped recents list.
2693    pub fn use_cache(&mut self, cache: CacheManager) {
2694        self.cache = cache;
2695    }
2696
2697    /// Read `catalog.toml` from `dir`, and write it there, from now on. For a test whose
2698    /// Ctrl+D must not write into the config directory every test in a process shares.
2699    pub fn use_catalog_dir(&mut self, dir: &Path) -> Result<()> {
2700        self.app_config.read_catalog_files(Some(dir))
2701    }
2702
2703    /// Path of the dataset currently installed, if any. Exposed for tests that need to
2704    /// assert an abandoned load did not swap a dataset in after the fact.
2705    pub fn open_path(&self) -> Option<&Path> {
2706        self.path.as_deref()
2707    }
2708
2709    /// Whether the dataset on screen was piped in: named `stdin`, with no file behind
2710    /// that name.
2711    fn reads_stdin(&self) -> bool {
2712        self.opened
2713            .as_ref()
2714            .is_some_and(|(paths, _)| matches!(paths.as_slice(), [path] if stdin::is_stdin(path)))
2715    }
2716
2717    /// What views are matched against: the dataset's path and the table of its file it
2718    /// is. What was piped in, or a frame handed over (`datui.view(frame)`), is `-`,
2719    /// which no path criterion fits, so it matches by its columns alone.
2720    fn view_dataset(&self) -> Option<view::Dataset<'_>> {
2721        self.data_table_state.as_ref()?;
2722        let path = match self.path.as_deref() {
2723            Some(path) if !self.reads_stdin() => path,
2724            _ => Path::new(stdin::PATH),
2725        };
2726        Some(view::Dataset {
2727            path,
2728            table: self.view_table(),
2729        })
2730    }
2731
2732    /// The table of a file of tables the dataset on screen is: the one named by
2733    /// `--table`, or by a path inside the file (`shop.db/orders`), which opens as the
2734    /// file with `--table`.
2735    fn view_table(&self) -> Option<&str> {
2736        let (_, options) = self.opened.as_ref()?;
2737        options.table.as_deref()
2738    }
2739
2740    /// Whether any leased background work, current or abandoned, has yet to report
2741    /// back. Exposed for tests that wait for abandoned work to finish rather than
2742    /// guessing how long it takes.
2743    pub fn background_work_in_flight(&self) -> bool {
2744        self.jobs.in_flight()
2745    }
2746
2747    /// See the `screen_generation` field.
2748    pub fn screen_generation(&self) -> u64 {
2749        self.screen_generation
2750    }
2751
2752    /// True while a message is in front of the user that has to be dismissed.
2753    pub fn modal_showing(&self) -> bool {
2754        self.error_modal.active || self.confirmation_modal.active
2755    }
2756
2757    /// The analysis on screen was run on a sample: what `r` and `a` act on.
2758    fn analysis_results_are_sampled(&self) -> bool {
2759        self.analysis_modal.view == analysis_modal::AnalysisView::Main
2760            && self.analysis_modal.computing.is_none()
2761            && self
2762                .analysis_modal
2763                .current_results()
2764                .is_some_and(|r| r.sample_size.is_some())
2765    }
2766
2767    /// An analysis cancelled whose worker has not exited, and when it was cancelled.
2768    /// While it runs, Data Quality does not start another beside it, and says so.
2769    pub(crate) fn cancelled_analysis_running(&self) -> Option<std::time::Instant> {
2770        self.cancelled_analysis().map(|(since, _)| since)
2771    }
2772
2773    /// The newest cancelled analysis or sample read still running, and whether it was
2774    /// cancelled during a read nothing can stop.
2775    fn cancelled_analysis(&self) -> Option<(std::time::Instant, bool)> {
2776        let (since, job) = self.jobs.cancelled_running(Self::reads_for_analysis)?;
2777        let runs_out = match job {
2778            Job::Analysis(run) => run.runs_out,
2779            _ => true,
2780        };
2781        Some((since, runs_out))
2782    }
2783
2784    /// A read for the Analysis tools: a run, or the sample read to show as a table.
2785    fn reads_for_analysis(job: &Job) -> bool {
2786        matches!(job, Job::Analysis(_) | Job::SampleRows)
2787    }
2788
2789    /// A cancelled run still going that the screen should say is: at once when the
2790    /// cancel came during a read nothing can stop, and otherwise only once it has
2791    /// outlasted the batch it was to stop after.
2792    pub(crate) fn cancelled_run_shown(&self) -> Option<crate::widgets::data_quality::Cancelling> {
2793        let (since, read_runs_out) = self.cancelled_analysis()?;
2794        (read_runs_out || since.elapsed() >= CANCEL_GRACE).then_some(
2795            crate::widgets::data_quality::Cancelling {
2796                since,
2797                read_runs_out,
2798            },
2799        )
2800    }
2801
2802    /// Work a cancel passed that is still running: leased on a generation since left.
2803    fn cancelled_work_running(&self) -> bool {
2804        self.jobs.running_behind()
2805    }
2806
2807    /// Where the value tools (Describe, Distribution, Correlation) read the shared
2808    /// sample from, and what the table already knows of its size. Row ranges are
2809    /// counted in the order the table shows; every other view scope reads without the
2810    /// sort, which no statistic needs and which makes a sampled read read everything.
2811    fn sample_source(&self, state: &DataTableState) -> (sampling::SampleSource, Option<usize>) {
2812        Self::sample_source_for(state, &self.analysis_modal.sample.scope)
2813    }
2814
2815    fn sample_source_for(
2816        state: &DataTableState,
2817        scope: &data_quality::QualityScope,
2818    ) -> (sampling::SampleSource, Option<usize>) {
2819        if scope.uses_source() {
2820            let (lf, source) = state.data_quality_source_scan();
2821            return (sampling::SampleSource::loaded(lf, source), None);
2822        }
2823        let lf = match scope {
2824            data_quality::QualityScope::FirstRows(_)
2825            | data_quality::QualityScope::ViewRows { .. } => state.lf().clone(),
2826            _ => state.analysis_lf(),
2827        };
2828        (
2829            sampling::SampleSource::view(lf.select(state.binary_stub_exprs())),
2830            sampling::view_scope_rows(state.num_rows_if_valid(), scope),
2831        )
2832    }
2833
2834    /// Open the Sample form on a copy of the shared sample. A per-partition sample
2835    /// splits by a column; partition columns lead the choices, then the columns a
2836    /// partition is usually made of (text, integers, dates), never floats.
2837    fn open_sample_form(&mut self) {
2838        self.open_sample_form_as(false);
2839    }
2840
2841    /// A tool with nothing to show yet: the Sample form is its pane, as it stands.
2842    /// Where the cursor goes is the caller's: into the form when the tool is picked,
2843    /// back to the tool list when Esc leaves it.
2844    fn open_first_run_form(&mut self) {
2845        self.open_sample_form_as(true);
2846        self.sync_sample_form_focus();
2847    }
2848
2849    /// The scope field shows its cursor only while the form has the cursor.
2850    fn sync_sample_form_focus(&mut self) {
2851        let focused = self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main;
2852        if let Some(form) = self.analysis_modal.sample_form.as_mut() {
2853            let has_cursor = focused || !form.inline;
2854            form.sync_focus(has_cursor);
2855        }
2856    }
2857
2858    /// Run the tool on screen with the Sample form's sample, or say on the form why
2859    /// its scope does not parse. In Data Quality the sample is staged in Setup's
2860    /// draft instead: the form applies, and Run reads.
2861    fn run_sample_form(&mut self) -> Option<AppEvent> {
2862        let quality =
2863            self.analysis_modal.selected_tool == Some(analysis_modal::AnalysisTool::DataQuality);
2864        // The view's sample: it is drawn again, and the tool runs on it once it is.
2865        if self.analysis_modal.sample_form.as_ref()?.view {
2866            let memory = self.memory_check();
2867            let form = self.analysis_modal.sample_form.as_mut()?;
2868            return match Self::submit_view_sample(form, memory) {
2869                sample_keys::Submitted::Stays => None,
2870                sample_keys::Submitted::Clear => {
2871                    self.analysis_modal.sample_form = None;
2872                    self.clear_table_sample();
2873                    if quality {
2874                        None
2875                    } else {
2876                        self.start_analysis_run()
2877                    }
2878                }
2879                sample_keys::Submitted::Draw { sample, anyway } => {
2880                    self.analysis_modal.sample_form = None;
2881                    self.apply_table_sample(sample, None, anyway, !quality);
2882                    if !quality {
2883                        self.analysis_modal.computing =
2884                            Some(AnalysisProgress::new("Drawing the sample"));
2885                    }
2886                    None
2887                }
2888            };
2889        }
2890        let finished = self.analysis_modal.sample_form.as_mut()?.finish();
2891        match finished {
2892            Ok(sample) if quality => {
2893                self.analysis_modal.sample_form = None;
2894                self.analysis_modal.data_quality_plan.adopt_sample(&sample);
2895                self.analysis_modal.data_quality_setup_note = None;
2896                None
2897            }
2898            // The form stays open, as filled, while a cancelled run finishes.
2899            Ok(_) if self.read_waits_for_cancelled() => None,
2900            Ok(sample) => {
2901                self.analysis_modal.sample_form = None;
2902                self.apply_sample(sample)
2903            }
2904            Err(error) => {
2905                if let Some(form) = self.analysis_modal.sample_form.as_mut() {
2906                    form.error = Some(error);
2907                }
2908                None
2909            }
2910        }
2911    }
2912
2913    fn open_sample_form_as(&mut self, inline: bool) {
2914        let sample = self.analysis_modal.sample.clone();
2915        self.open_sample_form_on(&sample, inline);
2916    }
2917
2918    /// `s` in Data Quality: the Sample form over Setup, on the draft's sample. Its
2919    /// Enter stages the sample in the draft and returns to Setup; only Run reads.
2920    fn open_quality_sample_form(&mut self) {
2921        self.open_quality_setup();
2922        let sample = self.analysis_modal.data_quality_plan.sample();
2923        self.open_sample_form_on(&sample, false);
2924    }
2925
2926    fn open_sample_form_on(&mut self, sample: &sampling::Sample, inline: bool) {
2927        let Some(state) = self.data_table_state.as_ref() else {
2928            return;
2929        };
2930        // A view with a sample: the form edits it, and the rows come from the view it
2931        // was drawn from.
2932        let (sample, view) = match state.sampled() {
2933            Some(sampled) => (sampled.sample().clone(), true),
2934            None => (sample.clone(), false),
2935        };
2936        let context = self.sample_context(state.unsampled());
2937        let mut form = sample_modal::SampleForm::new(&sample, context, &self.theme);
2938        form.inline = inline;
2939        form.view = view;
2940        form.bytes_per_row = Some(state.unsampled().sample_row_bytes(false));
2941        form.source_bytes_per_row = Some(state.unsampled().sample_row_bytes(true));
2942        self.analysis_modal.sample_form = Some(form);
2943        self.sync_sample_form_focus();
2944    }
2945
2946    /// What the Sample form offers for `state`'s rows: its partitions, files, time
2947    /// columns and the columns an equal-per-value sample can split by.
2948    pub(crate) fn sample_context(&self, state: &DataTableState) -> sample_modal::SampleContext {
2949        let mut partition_columns = state.partition_columns().unwrap_or_default().to_vec();
2950        let mut partition_values = Vec::new();
2951        // A directory whose files agree opens as one scan and names no partition
2952        // columns; its directory names still do. One branch of the tree is walked for
2953        // the columns and one listing read for the first column's values: local,
2954        // and small next to opening the dataset.
2955        if let Some(dir) = self.path.as_ref().filter(|path| path.is_dir()) {
2956            if partition_columns.is_empty() {
2957                partition_columns = DataTableState::discover_hive_partition_columns(dir)
2958                    .into_iter()
2959                    .filter(|column| state.schema().get(column).is_some())
2960                    .collect();
2961            }
2962            if let Some(first) = partition_columns.first() {
2963                let prefix = format!("{first}=");
2964                let mut values: Vec<String> = std::fs::read_dir(dir)
2965                    .into_iter()
2966                    .flatten()
2967                    .flatten()
2968                    .filter_map(|entry| {
2969                        let name = entry.file_name().to_string_lossy().to_string();
2970                        name.strip_prefix(&prefix).map(str::to_string)
2971                    })
2972                    .collect();
2973                values.sort();
2974                if !values.is_empty() {
2975                    partition_values.push((first.clone(), values));
2976                }
2977            }
2978        }
2979        // An equal-per-value sample splits by a column: partition columns first, then
2980        // text, the usual stuff of a group (a ticker, a region), then dates and
2981        // integers. Never floats.
2982        let mut value_columns = partition_columns.clone();
2983        for kind in 0..3 {
2984            for (name, dtype) in state.schema().iter() {
2985                let rank = match dtype {
2986                    DataType::String | DataType::Categorical(..) | DataType::Boolean => 0,
2987                    DataType::Date => 1,
2988                    dtype if dtype.is_integer() => 2,
2989                    _ => continue,
2990                };
2991                if rank == kind && !value_columns.iter().any(|column| column == name.as_str()) {
2992                    value_columns.push(name.to_string());
2993                }
2994            }
2995        }
2996        sample_modal::SampleContext {
2997            view_rows: state.num_rows_if_valid(),
2998            filtered: state.changes_rows(),
2999            files: state.quality_source_file_names().to_vec(),
3000            partition_columns,
3001            partition_values,
3002            time_columns: state.quality_temporal_columns(&data_quality::QualityScope::WholeSource),
3003            value_columns,
3004        }
3005    }
3006
3007    fn sample_form_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
3008        let form = self.analysis_modal.sample_form.as_mut()?;
3009        let file_count = form.context.files.len();
3010        let key = form::key(form, event);
3011        match key {
3012            // In a tool's empty pane the form stays, as it was: Esc discards the
3013            // edit and hands the cursor back to the tool list.
3014            FormKey::Cancel if form.inline => {
3015                self.analysis_modal.focus = analysis_modal::AnalysisFocus::Sidebar;
3016                self.open_first_run_form();
3017            }
3018            FormKey::Cancel => self.analysis_modal.sample_form = None,
3019            FormKey::Submit => return self.run_sample_form(),
3020            FormKey::Step(_, delta) => {
3021                form.adjust(delta > 0);
3022                form.edited();
3023            }
3024            FormKey::Text(sample_modal::SampleField::Files)
3025                if matches!(event.code, KeyCode::PageDown | KeyCode::PageUp) =>
3026            {
3027                form.file_offset = if event.code == KeyCode::PageDown {
3028                    (form.file_offset + crate::widgets::sample_form::FILES_SHOWN)
3029                        .min(file_count.saturating_sub(1))
3030                } else {
3031                    form.file_offset
3032                        .saturating_sub(crate::widgets::sample_form::FILES_SHOWN)
3033                };
3034            }
3035            FormKey::Text(_) => {
3036                if let Some(input) = form.input_mut(form.field) {
3037                    let _ = input.handle_key(event, None);
3038                }
3039                form.edited();
3040            }
3041            FormKey::Act(_) | FormKey::Moved | FormKey::Other => {}
3042        }
3043        None
3044    }
3045
3046    /// Read the shared sample, as the tool on screen reads it, to show as a table.
3047    ///
3048    /// Data Quality's last sample is kept and cut when it is these rows; any other is
3049    /// drawn again from its seed, which makes it the same rows the tool measured.
3050    fn read_sample_view(&mut self) -> Option<AppEvent> {
3051        let sample = self.analysis_modal.sample.clone();
3052        self.read_sample_rows(sample, None)
3053    }
3054
3055    /// Read `sample` off the UI thread and show its rows: all of them, or only a
3056    /// finding's, under the finding's label. The sample is drawn again from its seed,
3057    /// so these are the rows the tool measured.
3058    fn read_sample_rows(
3059        &mut self,
3060        sample: sampling::Sample,
3061        evidence: Option<(quality_report::EvidenceRows, String)>,
3062    ) -> Option<AppEvent> {
3063        let state = self.data_table_state.as_ref()?;
3064        let (source, known_total) = Self::sample_source_for(state, &sample.scope);
3065        let streaming = self.app_config.performance.streaming;
3066        // The rows Data Quality just measured, when they are the rows asked for: cut
3067        // from memory rather than drawn again from the files.
3068        let kept = self.kept_quality_sample(&sample).map(|kept| {
3069            let columns: Vec<_> = state
3070                .schema()
3071                .iter_names()
3072                .filter(|name| kept.df().column(name.as_str()).is_ok())
3073                .map(|name| polars::prelude::col(name.clone()))
3074                .collect();
3075            (kept, columns)
3076        });
3077        if kept.is_none() && self.read_waits_for_cancelled() {
3078            return None;
3079        }
3080        self.analysis_modal.computing = Some(AnalysisProgress::new(if evidence.is_some() {
3081            "Reading the matching sampled rows"
3082        } else {
3083            "Reading the sample"
3084        }));
3085        self.spawn_job(Job::SampleRows, Some("Reading the sample..."), move |_| {
3086            // The columns shown are the table's; a finding is cut from every column
3087            // the run read first, so duplicates are judged as the run judged them.
3088            let (rows, columns) = match kept {
3089                Some((kept, columns)) => (Ok(kept.analysis_rows(kept.df().clone())), Some(columns)),
3090                None => (
3091                    source
3092                        .cut(&sample.scope)
3093                        .and_then(|lf| sampling::read(&lf, &sample, known_total, streaming)),
3094                    None,
3095                ),
3096            };
3097            let shown = |df: polars::prelude::DataFrame| match &columns {
3098                Some(columns) => polars::prelude::IntoLazy::lazy(df)
3099                    .select(columns.clone())
3100                    .collect()
3101                    .map_err(color_eyre::eyre::Report::from),
3102                None => Ok(df),
3103            };
3104            let read = rows.and_then(|rows| {
3105                let label = format!(
3106                    "Sample {} {}",
3107                    crate::glyphs::get().middot,
3108                    sample.outcome(
3109                        rows.total_rows,
3110                        rows.sample_size,
3111                        rows.per_value.as_ref().map(|per_value| per_value.kept),
3112                    )
3113                );
3114                match evidence {
3115                    Some((quality_report::EvidenceRows::Duplicates, label)) => {
3116                        // Every column the run grouped by: the scope's own, not the
3117                        // row numbers kept beside them.
3118                        let keys = rows
3119                            .df
3120                            .get_column_names()
3121                            .into_iter()
3122                            .filter(|name| !name.starts_with("__datui"))
3123                            .cloned()
3124                            .collect::<Vec<_>>();
3125                        let df = data_quality::duplicate_rows(
3126                            polars::prelude::IntoLazy::lazy(rows.df),
3127                            &keys,
3128                            streaming,
3129                        )?;
3130                        Ok((shown(df)?, label))
3131                    }
3132                    Some((quality_report::EvidenceRows::Matching(predicate), label)) => {
3133                        let df = polars::prelude::IntoLazy::lazy(rows.df)
3134                            .filter(predicate)
3135                            .collect()?;
3136                        Ok((shown(df)?, label))
3137                    }
3138                    // Files are read from the scope, never from a sample.
3139                    Some((quality_report::EvidenceRows::Files(_), label)) => {
3140                        Ok((shown(rows.df)?, label))
3141                    }
3142                    None => Ok((shown(rows.df)?, label)),
3143                }
3144            });
3145            let (df, label) = read.map_err(|error| format!("{error}"))?;
3146            Ok(Answer::Sample { df, label })
3147        });
3148        None
3149    }
3150
3151    /// Put the sample's rows in the table viewer in place of the table, as Data
3152    /// Quality's drill-in does; Esc brings the table and Analysis back.
3153    fn show_sample_view(&mut self, df: polars::prelude::DataFrame, label: String) {
3154        let Some(state) = self.data_table_state.as_ref() else {
3155            return;
3156        };
3157        let view = match state.sample_view(df) {
3158            Ok(view) => view,
3159            Err(error) => {
3160                self.error_modal
3161                    .show(format!("Cannot show the sample: {error}"));
3162                return;
3163            }
3164        };
3165        if let Some(original) = self.data_table_state.replace(view) {
3166            self.quality_evidence_return = Some(Box::new(original));
3167            self.quality_evidence_label = Some(label);
3168            self.analysis_modal.active = false;
3169            self.forget_the_rows_read();
3170            self.spawn_async_collect("Loading the sample...");
3171        }
3172    }
3173
3174    /// Mirror the shared sample into the Data Quality plan, which carries it into the
3175    /// engine and into the session cache's key. Metadata-only stays metadata-only.
3176    fn sync_quality_plan(&mut self) {
3177        let sample = self.analysis_modal.sample.clone();
3178        self.analysis_modal.data_quality_plan.adopt_sample(&sample);
3179    }
3180
3181    /// Open Data Quality Setup: the plan, staged. Edits wait for Run, and Esc puts
3182    /// back the plan as it stood here. Opening it again while open changes nothing.
3183    fn open_quality_setup(&mut self) {
3184        use data_quality::QualityPage;
3185        let modal = &mut self.analysis_modal;
3186        if !modal.data_quality_page.is_setup() {
3187            modal.data_quality_setup_return = modal.data_quality_page.tab();
3188        }
3189        if modal.data_quality_setup_before.is_none() {
3190            modal.data_quality_setup_before = Some(modal.data_quality_plan.clone());
3191        }
3192        if modal.data_quality_page != QualityPage::Setup {
3193            modal.set_quality_page(QualityPage::Setup);
3194            modal.data_quality_plan_field = 0;
3195        }
3196        modal.focus = analysis_modal::AnalysisFocus::Main;
3197    }
3198
3199    /// Esc on Setup: every staged edit goes, and the report it came from comes back.
3200    /// With no report yet, Setup stays in the pane and the cursor goes to the tools.
3201    fn leave_quality_setup(&mut self) {
3202        use data_quality::QualityPage;
3203        let modal = &mut self.analysis_modal;
3204        if let Some(before) = modal.data_quality_setup_before.take() {
3205            modal.data_quality_plan = before;
3206        }
3207        modal.data_quality_setup_note = None;
3208        modal.data_quality_confirm_run = false;
3209        modal.data_quality_picker = None;
3210        if modal.data_quality_results.is_some() {
3211            let back = match modal.data_quality_setup_return {
3212                page if page.is_setup() => QualityPage::Overview,
3213                page => page,
3214            };
3215            modal.set_quality_page(back);
3216        } else {
3217            modal.set_quality_page(QualityPage::Setup);
3218            modal.focus = analysis_modal::AnalysisFocus::Sidebar;
3219        }
3220    }
3221
3222    /// The confirmation a full scan asks: what it reads, what it fetches from a
3223    /// remote source, and that it writes nothing there.
3224    fn quality_full_scan_question(&self, plan: &data_quality::DataQualityPlan) -> String {
3225        let mut lines = vec![
3226            "Run a full scan?".to_string(),
3227            String::new(),
3228            "Reads: every eligible row, up to the whole source".to_string(),
3229        ];
3230        if let data_quality::CopyPlan::Fetch { bytes, .. } = self.quality_copy_plan(plan) {
3231            lines.push(format!(
3232                "Fetch: {} once, to a local copy",
3233                crate::widgets::info::format_bytes(bytes)
3234            ));
3235        }
3236        lines.push("Source writes: none".to_string());
3237        lines.join("\n")
3238    }
3239
3240    /// What stops Setup from running as it stands, said on its own line: a time
3241    /// window on text that has no format to read it with.
3242    fn quality_setup_problem(&self) -> Option<String> {
3243        let plan = &self.analysis_modal.data_quality_plan;
3244        let schema = self.data_table_state.as_ref()?.quality_schema(&plan.scope);
3245        match &plan.grain {
3246            data_quality::QualityGrain::TimeWindows { column, .. }
3247                if plan.compute != data_quality::QualityCompute::Metadata
3248                    && !plan.reads_as_time(column, schema) =>
3249            {
3250                Some(format!(
3251                    "{column}: text, no format {} set Text as time",
3252                    crate::glyphs::get().middot
3253                ))
3254            }
3255            _ => None,
3256        }
3257    }
3258
3259    /// Run, from Setup: the one place a Data Quality run starts. The draft becomes
3260    /// the plan, and its sample the one every tool reads; then the report for it is
3261    /// shown if one is already here, and otherwise read, once.
3262    ///
3263    /// Waits, with the reason on Setup, while a cancelled run is still stopping: a
3264    /// second read beside it is how memory runs out. A full scan asks first, and
3265    /// Esc there leaves the draft staged and the last report as it was.
3266    fn run_quality_setup(&mut self) -> Option<AppEvent> {
3267        use data_quality::QualityPage;
3268        if self.cancelled_analysis_running().is_some() {
3269            self.analysis_modal.data_quality_confirm_run = false;
3270            self.analysis_modal.data_quality_setup_note = Some(QUALITY_RUN_WAITS.to_string());
3271            return None;
3272        }
3273        if let Some(problem) = self.quality_setup_problem() {
3274            self.analysis_modal.data_quality_setup_note = Some(problem);
3275            return None;
3276        }
3277        // A report already here, on screen or cached, reads nothing: nothing to confirm.
3278        let plan = &self.analysis_modal.data_quality_plan;
3279        let here = (self.analysis_modal.data_quality_results.is_some()
3280            && self
3281                .analysis_modal
3282                .data_quality_last_plan
3283                .as_ref()
3284                .is_some_and(|last| last.same_measurement(plan)))
3285            || self.quality_cached(plan);
3286        if plan.requires_confirmation() && !here && !self.analysis_modal.data_quality_confirm_run {
3287            // Asked with the one confirmation; its Yes comes back here.
3288            let message = self.quality_full_scan_question(plan);
3289            self.analysis_modal.data_quality_confirm_run = true;
3290            self.confirmation_modal.show(message);
3291            self.confirmation_modal.yes_label = "Run";
3292            return None;
3293        }
3294        self.analysis_modal.data_quality_confirm_run = false;
3295        self.commit_quality_plan();
3296        let modal = &mut self.analysis_modal;
3297        if modal.data_quality_results.is_some()
3298            && modal.data_quality_last_plan.as_ref() == Some(&modal.data_quality_plan)
3299        {
3300            let back = match modal.data_quality_setup_return {
3301                page if page.is_setup() => QualityPage::Overview,
3302                page => page,
3303            };
3304            modal.set_quality_page(back);
3305            return None;
3306        }
3307        // Only the expected windows or the comparison changed: the report on screen
3308        // holds every count the windows are checked against and every segment the
3309        // comparison is worked out from, so it is relabeled, not read again.
3310        if let (Some(results), Some(last)) = (
3311            modal.data_quality_results.as_ref(),
3312            modal.data_quality_last_plan.as_ref(),
3313        ) && last.same_measurement(&modal.data_quality_plan)
3314        {
3315            let mut results = results.clone();
3316            let plan = modal.data_quality_plan.clone();
3317            let page = if last.compares_differently(&plan) {
3318                results.compare_segments(&plan);
3319                QualityPage::Segments
3320            } else {
3321                QualityPage::Trends
3322            };
3323            modal.data_quality_results = Some(results.clone());
3324            modal.data_quality_last_plan = Some(plan.clone());
3325            modal.set_quality_page(page);
3326            self.cache_quality_result(&results, plan);
3327            return None;
3328        }
3329        if self.restore_cached_quality() {
3330            return None;
3331        }
3332        self.analysis_modal.data_quality_from_cache = false;
3333        let mut progress = AnalysisProgress::new("Preparing the plan");
3334        if self.quality_kept_serves(&self.analysis_modal.data_quality_plan) {
3335            progress.reuse = Some("Starts from rows a run already read".to_string());
3336        }
3337        self.analysis_modal.computing = Some(progress);
3338        self.busy = true;
3339        Some(AppEvent::AnalysisDataQualityCompute)
3340    }
3341
3342    /// The draft is the plan now: Setup closes on it, and its sample becomes the
3343    /// one every tool reads. The other tools' results were of the old sample, so
3344    /// they go; Data Quality's last report stays, labeled with what it measured,
3345    /// until the run replaces it.
3346    fn commit_quality_plan(&mut self) {
3347        let modal = &mut self.analysis_modal;
3348        let sample = modal.data_quality_plan.sample();
3349        if sample != modal.sample {
3350            modal.describe_results = None;
3351            modal.distribution_results = None;
3352            modal.correlation_results = None;
3353        }
3354        modal.sample = sample;
3355        modal.sample_dataset = Some(self.dataset_generation);
3356        modal.sample_run_for = Some(self.dataset_generation);
3357        modal.data_quality_setup_before = None;
3358        modal.data_quality_setup_note = None;
3359        modal.data_quality_picker = None;
3360    }
3361
3362    /// Adopt a new shared sample: every tool's results were of the old one, so all of
3363    /// them go, and the tool on screen runs again. Data Quality only takes it into
3364    /// its plan: nothing reads until its Run.
3365    fn apply_sample(&mut self, sample: sampling::Sample) -> Option<AppEvent> {
3366        // A run it would start waits for a cancelled one, with every result kept.
3367        if self.analysis_modal.selected_tool != Some(analysis_modal::AnalysisTool::DataQuality)
3368            && self.read_waits_for_cancelled()
3369        {
3370            return None;
3371        }
3372        // A first run on the sample as it stands takes nothing from the other tools.
3373        if sample != self.analysis_modal.sample {
3374            self.analysis_modal.describe_results = None;
3375            self.analysis_modal.distribution_results = None;
3376            self.analysis_modal.correlation_results = None;
3377            self.analysis_modal.data_quality_results = None;
3378            self.analysis_modal.data_quality_last_plan = None;
3379            self.analysis_modal.data_quality_from_cache = false;
3380        }
3381        self.analysis_modal.sample = sample;
3382        self.analysis_modal.sample_dataset = Some(self.dataset_generation);
3383        self.analysis_modal.sample_run_for = Some(self.dataset_generation);
3384        self.sync_quality_plan();
3385        if self.analysis_modal.selected_tool == Some(analysis_modal::AnalysisTool::DataQuality) {
3386            return None;
3387        }
3388        self.start_analysis_run()
3389    }
3390
3391    /// A cancelled analysis is still reading: say so, and start no read beside it.
3392    fn read_waits_for_cancelled(&mut self) -> bool {
3393        if self.cancelled_analysis_running().is_none() {
3394            return false;
3395        }
3396        self.flash_note(ANALYSIS_READ_WAITS.to_string());
3397        true
3398    }
3399
3400    /// Run the selected tool again from scratch, as `r` and `a` do.
3401    fn start_analysis_run(&mut self) -> Option<AppEvent> {
3402        let tool = self.analysis_modal.selected_tool?;
3403        if tool != analysis_modal::AnalysisTool::DataQuality && self.read_waits_for_cancelled() {
3404            return None;
3405        }
3406        let (phase, event) = match tool {
3407            analysis_modal::AnalysisTool::Describe => {
3408                self.analysis_modal.describe_results = None;
3409                self.analysis_computation = Some(AnalysisComputationState {
3410                    df: None,
3411                    schema: None,
3412                    partial_stats: Vec::new(),
3413                    current: 0,
3414                    total: 0,
3415                    total_rows: 0,
3416                    sample_seed: self.analysis_modal.sample.seed,
3417                    sample_size: None,
3418                });
3419                ("Describing data", AppEvent::AnalysisChunk)
3420            }
3421            analysis_modal::AnalysisTool::DistributionAnalysis => {
3422                self.analysis_modal.distribution_results = None;
3423                (
3424                    "Analyzing distributions",
3425                    AppEvent::AnalysisDistributionCompute,
3426                )
3427            }
3428            analysis_modal::AnalysisTool::CorrelationMatrix => {
3429                self.analysis_modal.correlation_results = None;
3430                (
3431                    "Computing correlations",
3432                    AppEvent::AnalysisCorrelationCompute,
3433                )
3434            }
3435            analysis_modal::AnalysisTool::DataQuality => return None,
3436        };
3437        self.analysis_modal.computing = Some(AnalysisProgress::new(phase));
3438        self.busy = true;
3439        Some(event)
3440    }
3441
3442    /// Stop waiting for the analysis in flight.
3443    ///
3444    /// The worker's answer is dropped: the bump makes it stale, and its lease no longer
3445    /// holds the table up. A Data Quality run stops at its next batch or stage; a
3446    /// collect nothing watches runs to its end. The tool is put back unchosen, so its
3447    /// view does not sit on a spinner for a run that is not coming; Enter on it runs
3448    /// it again.
3449    fn cancel_analysis(&mut self) {
3450        // The run's record says it is still running until its worker ends, superseded
3451        // below. Only a Data Quality run has a watch to stop it by, and only its stages
3452        // say whether they stop partway.
3453        let reads_out = self
3454            .analysis_modal
3455            .computing
3456            .as_ref()
3457            .is_some_and(AnalysisProgress::read_runs_out);
3458        let mut read_runs_out = true;
3459        if let Some(Job::Analysis(run)) =
3460            self.jobs.current_mut(|job| matches!(job, Job::Analysis(_)))
3461        {
3462            read_runs_out = run.watch.is_none() || reads_out;
3463            run.runs_out = read_runs_out;
3464            if let Some(watch) = &run.watch {
3465                watch.cancel();
3466            }
3467        }
3468        let reading_sample = self
3469            .jobs
3470            .current(|job| matches!(job, Job::SampleRows))
3471            .is_some();
3472        self.jobs.cancel(Self::reads_for_analysis);
3473        self.jobs.advance();
3474        // Keys typed while it ran were typed at the run, which is gone: an impatient
3475        // second Enter replayed now would start it again behind the Esc.
3476        self.screen_generation = self.screen_generation.wrapping_add(1);
3477        self.analysis_modal.computing = None;
3478        self.analysis_computation = None;
3479        self.busy = false;
3480        self.status_message = None;
3481        // Reading the sample to look at changed nothing on screen; the tool stays.
3482        if reading_sample {
3483            self.flash_note("Row view cancelled".to_string());
3484            return;
3485        }
3486        // Data Quality keeps its last report and goes back to Setup, where the plan
3487        // can be edited while the run winds down. A read that runs to its end is
3488        // state the header and Setup hold until the worker exits, which a flash could
3489        // not; a run that stops at its next batch is done, and a flash says so.
3490        if self.analysis_modal.selected_tool == Some(analysis_modal::AnalysisTool::DataQuality) {
3491            self.open_quality_setup();
3492            if !read_runs_out {
3493                self.flash_note("Run cancelled".to_string());
3494            }
3495            return;
3496        }
3497        self.analysis_modal.selected_tool = None;
3498        self.analysis_modal.focus = analysis_modal::AnalysisFocus::Sidebar;
3499        self.flash_note("Analysis cancelled".to_string());
3500    }
3501
3502    /// Whether the Pivot & Melt builder is waiting on a pivot it started.
3503    pub(crate) fn pivot_computing(&self) -> bool {
3504        self.input_mode == InputMode::PivotMelt
3505            && self.jobs.current(|job| matches!(job, Job::Pivot)).is_some()
3506    }
3507
3508    /// Stop waiting for the pivot in flight. As with an analysis, the worker runs to
3509    /// the end and the bump drops its answer. The form stays open with its spec.
3510    fn cancel_pivot(&mut self) {
3511        self.jobs.advance();
3512        self.screen_generation = self.screen_generation.wrapping_add(1);
3513        self.busy = false;
3514        self.status_message = None;
3515        self.flash_note("Pivot cancelled".to_string());
3516    }
3517
3518    /// Drill into the group on row `group_index` of the table, whose values are `row`,
3519    /// and fetch its rows off the UI thread. A drill that fails says why on the control
3520    /// bar and leaves the grouped view as it was.
3521    fn drill_into(&mut self, group_index: usize, row: &DataFrame) {
3522        let Some(state) = self.data_table_state.as_mut() else {
3523            return;
3524        };
3525        let drilled = state.deferred(|s| s.drill_down_with_row(group_index, row));
3526        match drilled {
3527            Ok(()) => {
3528                self.sync_sort_filter_modal();
3529                self.spawn_async_collect(Self::LOADING_BUFFER);
3530            }
3531            Err(e) => self.flash_note(format!(
3532                "Could not drill in: {}",
3533                crate::error_display::user_message_from_report(&e, None)
3534            )),
3535        }
3536    }
3537
3538    /// Read `reader` where `-` reads standard input: what a test pipes in.
3539    #[doc(hidden)]
3540    pub fn read_stdin_from(&mut self, reader: impl std::io::Read + Send + 'static) {
3541        self.stdin_reader = Some(Box::new(reader));
3542    }
3543
3544    /// Pass the stream on to `out` for `--tee -`: standard output as the process got
3545    /// it, or a test's pipe.
3546    #[doc(hidden)]
3547    pub fn pass_stdout_to(&mut self, out: impl std::io::Write + Send + 'static) {
3548        self.stdout_pass = Some(Box::new(out));
3549    }
3550
3551    /// The follow of the dataset on screen, while it is followed.
3552    pub fn follow(&self) -> Option<&crate::follow::Follow> {
3553        self.data_table_state.as_ref()?.follow()
3554    }
3555
3556    /// Whether the follow's rows are on hand: none read in the background is still out,
3557    /// and the view has taken what was counted. A refresh holds no keys, so a test waits
3558    /// on this rather than on `is_busy`.
3559    #[doc(hidden)]
3560    pub fn follow_settled(&self) -> bool {
3561        self.rows_in_flight().is_none() && !self.follow().is_some_and(|f| f.behind())
3562    }
3563
3564    /// Ask the follow's watcher to look now rather than at the end of its interval.
3565    #[doc(hidden)]
3566    pub fn check_follow_now(&self) {
3567        if let Some(follow) = self.follow() {
3568            follow.check_now();
3569        }
3570    }
3571
3572    /// A followed file's watcher reported: what it counted waits for the view to take
3573    /// it, and what the user has to know is flashed.
3574    fn followed(&mut self, news: &crate::follow::News) {
3575        let Some(state) = self.data_table_state.as_mut() else {
3576            return;
3577        };
3578        let Some(follow) = state.follow_mut().filter(|f| f.id() == news.id) else {
3579            return;
3580        };
3581        let message = follow.take(&news.change);
3582        let fields = follow.take_new_fields();
3583        if let Some(handle) = follow.take_held() {
3584            state.read_followed_through(&handle);
3585        }
3586        if let Some(message) = message {
3587            self.flash_note(message);
3588        }
3589        if !fields.is_empty() {
3590            self.followed_fields_held = Some((self.dataset_generation, fields));
3591        }
3592        self.catch_up_follow();
3593        self.join_followed_fields();
3594        self.describe_ended_journal();
3595    }
3596
3597    /// Read a piped journal's Info tab again once it has ended, over every entry: the
3598    /// one the open read describes the entries that had arrived then. Not a job, which
3599    /// the user would wait on; the table works meanwhile.
3600    fn describe_ended_journal(&mut self) {
3601        let Some(lf) = self
3602            .data_table_state
3603            .as_mut()
3604            .and_then(|state| state.ended_journal_to_describe())
3605        else {
3606            return;
3607        };
3608        let generation = self.dataset_generation;
3609        let tx = self.events.clone();
3610        self.runtime.spawn_blocking(move || {
3611            let detail = logging::catch_panic(|| crate::journal::summary(&lf).ok())
3612                .ok()
3613                .flatten();
3614            if let Some(detail) = detail {
3615                let _ = tx.send(AppEvent::FollowedDetail {
3616                    dataset_generation: generation,
3617                    detail: Box::new(detail),
3618                });
3619            }
3620        });
3621    }
3622
3623    /// Join the fields a followed pipe brought after the open, if the dataset can
3624    /// take them now, and read the rows on screen through the wider frame. Tried again
3625    /// after every event while they wait, as footers are.
3626    fn join_followed_fields(&mut self) {
3627        let Some((generation, _)) = self.followed_fields_held.as_ref() else {
3628            return;
3629        };
3630        if *generation != self.dataset_generation {
3631            self.followed_fields_held = None;
3632            return;
3633        }
3634        // Not under rows still being taken: the view reads the new rows first.
3635        if self.data_table_state.is_none()
3636            || self.work_the_join_would_cancel()
3637            || self.follow().is_some_and(|f| f.behind())
3638        {
3639            return;
3640        }
3641        let Some((generation, fields)) = self.followed_fields_held.take() else {
3642            return;
3643        };
3644        let Some(state) = self.data_table_state.as_mut() else {
3645            return;
3646        };
3647        match state.join_followed_fields(&fields) {
3648            Ok(true) => {
3649                self.spawn_async_collect(Self::LOADING_BUFFER);
3650            }
3651            Ok(false) => {}
3652            Err(()) => self.followed_fields_held = Some((generation, fields)),
3653        }
3654    }
3655
3656    /// Show the rows a follow counted, when the table is on screen with nothing
3657    /// running: a query, a sidebar, a takeover or a read in progress keeps the view it
3658    /// has until it is done. The cursor on the last row stays on the last row; anywhere
3659    /// else it stays put, and the rows below it are counted for the bar.
3660    fn catch_up_follow(&mut self) {
3661        if !self.in_normal_table_view()
3662            || self.is_busy()
3663            || self.loading.awaiting_dataset()
3664            || self.rows_in_flight().is_some()
3665        {
3666            return;
3667        }
3668        self.take_follow_rows(true);
3669    }
3670
3671    /// Give the view's frames the rows the follow counted. With `read`, the rows on
3672    /// screen are read too; without, the table reads them once it is back on screen.
3673    /// Returns whether there were rows to take.
3674    fn take_follow_rows(&mut self, read: bool) -> bool {
3675        let Some(state) = self.data_table_state.as_mut() else {
3676            return false;
3677        };
3678        let on_last_row = state.on_last_row();
3679        let counted = state.is_num_rows_valid();
3680        let drawn = state.visible_rows > 0 && counted;
3681        let Some(follow) = state.follow_mut() else {
3682            return false;
3683        };
3684        // The cursor goes to the last row once the rows that put it there are on hand:
3685        // moved before, the frame drawn meanwhile has fewer rows than the cursor's
3686        // place, and the table puts the cursor back on the last row it has.
3687        let settle = read && drawn && std::mem::take(&mut follow.settle_at_end);
3688        let to_end = settle || (read && counted && std::mem::take(&mut follow.end_pending));
3689        let stale = read && std::mem::take(&mut follow.stale_view);
3690        let at_bottom = to_end || on_last_row || follow.end_pending;
3691        if at_bottom {
3692            follow.new_below = 0;
3693        }
3694        if !follow.behind() {
3695            if (to_end && state.scroll_to_end()) || stale {
3696                self.spawn_collect(None);
3697            }
3698            return false;
3699        }
3700        let before = follow.shown();
3701        let (rows, restarted) = follow.catch_up();
3702        if at_bottom {
3703            follow.end_pending = true;
3704        } else if !restarted {
3705            follow.new_below += rows.saturating_sub(before);
3706        }
3707        follow.stale_view = !read;
3708        state.follow_to(rows, restarted);
3709        if at_bottom && read {
3710            if state.is_num_rows_valid() {
3711                state.aim_at_end();
3712            } else {
3713                // A filtered view's end is known once its count lands.
3714                self.end_after_count = Some(state.len_generation());
3715            }
3716        }
3717        if read && !self.spawn_collect(None) {
3718            // Nothing to read: the rows on hand already reach the end.
3719            self.catch_up_follow();
3720        }
3721        true
3722    }
3723
3724    /// `t` over a surface that keeps the rows it was opened on (Value Counts, Analysis,
3725    /// a chart): whether the follow has rows for it to take.
3726    fn follow_rows_waiting(&self) -> bool {
3727        self.follow().is_some_and(|f| f.behind()) && !self.loading.awaiting_dataset()
3728    }
3729
3730    /// Standard input being recorded to the file `--tee` named, for the dataset on
3731    /// screen.
3732    pub fn recording(&self) -> Option<&Arc<crate::follow::Spool>> {
3733        self.data_table_state.as_ref()?;
3734        self.opened
3735            .as_ref()
3736            .and_then(|(_, options)| options.spool.as_ref())
3737            .map(|handle| handle.spool())
3738            .filter(|spool| spool.tee().is_some())
3739    }
3740
3741    /// Where `key` takes the user out of the dataset: quitting, or home.
3742    fn leaves(&self, key: &KeyEvent) -> Option<Leaving> {
3743        if !key.is_press() {
3744            return None;
3745        }
3746        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
3747        match key.code {
3748            KeyCode::Char('q') if ctrl => Some(Leaving::Quit),
3749            KeyCode::Char('c') if ctrl => Some(Leaving::Quit),
3750            KeyCode::Char('o') if ctrl => Some(Leaving::Home),
3751            KeyCode::Char('Q') if !ctrl && self.in_normal_table_view() => Some(Leaving::Quit),
3752            KeyCode::Char('q') if !ctrl && self.in_normal_table_view() => {
3753                Some(if self.opened_from_home {
3754                    Leaving::Home
3755                } else {
3756                    Leaving::Quit
3757                })
3758            }
3759            _ => None,
3760        }
3761    }
3762
3763    /// Ask whether to stop the recording or keep it going while the user leaves.
3764    fn ask_about_recording(&mut self, leaving: Leaving) {
3765        let Some(tee) = self.recording().and_then(|spool| spool.tee()) else {
3766            return;
3767        };
3768        let doing = if tee.to_stdout() {
3769            "passed on"
3770        } else {
3771            "recorded"
3772        };
3773        let message = format!(
3774            "Standard input is still being {doing} to {}. Stop recording, or keep \
3775             recording until the stream ends?",
3776            tee.name()
3777        );
3778        self.pending_leave = Some(leaving);
3779        self.confirmation_modal
3780            .show_choice(message, "Stop recording", "Keep recording");
3781    }
3782
3783    /// Leave as asked: the recording stopped and its file finished, or kept going
3784    /// until its stream ends, while datui goes home or quits.
3785    fn leave_recording(&mut self, stop: bool) -> Option<AppEvent> {
3786        let leaving = self.pending_leave.take()?;
3787        let handle = self
3788            .opened
3789            .as_ref()
3790            .and_then(|(_, options)| options.spool.clone());
3791        if stop {
3792            if let Some(handle) = &handle {
3793                handle.spool().stop();
3794            }
3795        } else {
3796            // Held past the dataset, so letting it go does not stop the copy.
3797            self.recording_on = handle;
3798        }
3799        match leaving {
3800            Leaving::Quit => Some(AppEvent::Exit),
3801            Leaving::Home => {
3802                self.enter_home();
3803                None
3804            }
3805        }
3806    }
3807
3808    /// The recording to wait for once the terminal is handed back: kept going when
3809    /// the user quit, until its stream ends.
3810    pub fn recording_after_exit(
3811        &mut self,
3812    ) -> Option<(crate::follow::Tee, Arc<crate::follow::SpoolHandle>)> {
3813        let handle = self.recording_on.take()?;
3814        let spool = handle.spool();
3815        let tee = spool.tee()?.clone();
3816        spool.live().then_some((tee, handle))
3817    }
3818
3819    /// Say once that the recording ended: saved, or stopped by an error, which the
3820    /// error dialog says too. Returns true when the frame must redraw.
3821    fn notice_recording_end(&mut self) -> bool {
3822        let Some(spool) = self.recording().cloned() else {
3823            return false;
3824        };
3825        let Some(ended) = spool.ended() else {
3826            self.recording_end_said = false;
3827            return false;
3828        };
3829        if std::mem::replace(&mut self.recording_end_said, true) {
3830            return false;
3831        }
3832        let said = match spool.tee() {
3833            Some(tee) if tee.to_stdout() => "Standard input ended".to_string(),
3834            Some(tee) => format!("Saved {}", tee.path.display()),
3835            None => String::new(),
3836        };
3837        match ended {
3838            Some(reason) => self.error_modal.show(reason),
3839            None => self.flash_note(said),
3840        }
3841        true
3842    }
3843
3844    /// Show a completion flash on the control bar.
3845    fn flash_note(&mut self, message: String) {
3846        self.flash = Some(Flash::new(message));
3847    }
3848
3849    /// A completion flash that ends in the path written: `Exported to …/out.csv`.
3850    fn flash_path(&mut self, prefix: &str, path: &std::path::Path) {
3851        self.flash = Some(Flash::path(prefix, path));
3852    }
3853
3854    /// The completion flash on the control bar, if one is showing.
3855    pub fn flash_message(&self) -> Option<&str> {
3856        self.flash.as_ref().map(|f| f.message.as_str())
3857    }
3858
3859    /// The error dialog's message, if one is showing.
3860    pub fn error_message(&self) -> Option<&str> {
3861        self.error_modal
3862            .active
3863            .then_some(self.error_modal.message.as_str())
3864    }
3865
3866    /// When the screen next changes on its own, with no event to say so: the flash
3867    /// expiring. The run loop sleeps until then at most.
3868    pub fn next_deadline(&self) -> Option<std::time::Instant> {
3869        let flash = self.flash.as_ref().map(|f| f.expires);
3870        let clock = self
3871            .follow()
3872            .filter(|f| f.standing == crate::follow::Standing::Following)
3873            .and_then(|f| f.last_append)
3874            .map(crate::follow::next_tick);
3875        // A recording's size and rate move every second until it ends, and its end is
3876        // noticed on that tick.
3877        let recording = self
3878            .recording()
3879            .filter(|spool| spool.live())
3880            .map(|_| std::time::Instant::now() + std::time::Duration::from_secs(1));
3881        flash.into_iter().chain(clock).chain(recording).min()
3882    }
3883
3884    /// Whether the follow chip's clock reads differently now: the frame must redraw.
3885    pub fn tick_follow_clock(&mut self) -> bool {
3886        let ended = self.notice_recording_end();
3887        let now = self.follow_mark();
3888        if now == self.follow_drawn {
3889            return ended;
3890        }
3891        self.follow_drawn = now;
3892        true
3893    }
3894
3895    /// What the control bar says about the follow of the dataset on screen.
3896    fn follow_mark(&self) -> Option<crate::render::footer::FollowMark> {
3897        use crate::follow::Standing;
3898        // The hex view shows a file's bytes, not the table the follow moves.
3899        if self.input_mode == InputMode::Hex {
3900            return None;
3901        }
3902        let state = self.data_table_state.as_ref()?;
3903        let rows = |n: usize, what: &str| format!("{} {what}", crate::numfmt::group_chrome(n));
3904        let (rec, rec_stopped) = match self.recording().map(|spool| recording_label(spool)) {
3905            Some((label, stopped)) => (Some(label), stopped),
3906            None => (None, false),
3907        };
3908        let Some(follow) = state.follow().filter(|f| f.standing != Standing::Ended) else {
3909            return rec.is_some().then(|| crate::render::footer::FollowMark {
3910                rec,
3911                rec_stopped,
3912                ..Default::default()
3913            });
3914        };
3915        let waiting = follow.waiting();
3916        let (chip, note) = match follow.standing {
3917            // Standard input read as it arrives: how much has, until it ends.
3918            Standing::Following if follow.is_pipe() => {
3919                let read = follow.spool().map_or(0, |spool| spool.bytes());
3920                let chip = format!(
3921                    "reading stdin {} {}",
3922                    crate::glyphs::get().middot,
3923                    crate::discover::format_size(read)
3924                );
3925                let note = (follow.new_below > 0 && !state.on_last_row())
3926                    .then(|| rows(follow.new_below, "new below"));
3927                (chip, note)
3928            }
3929            Standing::Following => {
3930                let chip = match follow.last_append {
3931                    Some(at) => format!(
3932                        "following {} {}",
3933                        crate::glyphs::get().middot,
3934                        crate::follow::age(at.elapsed())
3935                    ),
3936                    None => "following".to_string(),
3937                };
3938                let note = if waiting > 0 && !self.in_normal_table_view() {
3939                    // A takeover keeps the rows it was opened on.
3940                    Some(rows(waiting, "new rows"))
3941                } else if follow.new_below > 0 && !state.on_last_row() {
3942                    Some(rows(follow.new_below, "new below"))
3943                } else {
3944                    None
3945                };
3946                (chip, note)
3947            }
3948            _ => (
3949                "paused".to_string(),
3950                (waiting > 0).then(|| rows(waiting, "new rows")),
3951            ),
3952        };
3953        let misfits = follow.misfits();
3954        // What `t` does on this screen: pause or resume at the table; over a surface
3955        // that keeps the rows it was opened on, read the new ones.
3956        let refreshes = self.input_mode == InputMode::ValueCounts
3957            || (self.input_mode == InputMode::Chart
3958                && self.chart_modal.picker.is_none()
3959                && !self.chart_export_modal.active)
3960            || (self.analysis_modal.active && self.analysis_modal.current_results().is_some());
3961        let key = if self.in_normal_table_view() {
3962            Some(match follow.standing {
3963                Standing::Paused => "Resume",
3964                _ => "Pause",
3965            })
3966        } else if refreshes && follow.behind() {
3967            Some("Refresh")
3968        } else {
3969            None
3970        };
3971        Some(crate::render::footer::FollowMark {
3972            key,
3973            chip: Some(chip),
3974            note,
3975            warning: (misfits > 0).then(|| {
3976                if misfits == 1 {
3977                    "1 row does not fit".to_string()
3978                } else {
3979                    rows(misfits, "rows do not fit")
3980                }
3981            }),
3982            rec,
3983            rec_stopped,
3984        })
3985    }
3986
3987    /// `t` at the table: pause or resume the follow, or follow the file, reading it
3988    /// again as `H` does.
3989    fn toggle_follow(&mut self) -> Option<AppEvent> {
3990        use crate::follow::Standing;
3991        if let Some(follow) = self.data_table_state.as_mut().and_then(|s| s.follow_mut()) {
3992            match follow.standing {
3993                Standing::Following => follow.pause(),
3994                Standing::Paused => {
3995                    follow.resume();
3996                    self.catch_up_follow();
3997                }
3998                Standing::Ended => {}
3999            }
4000            return None;
4001        }
4002        let (paths, options) = self.opened.clone()?;
4003        if paths.iter().any(|path| crate::stdin::is_stdin(path)) {
4004            self.flash_note("Standard input is followed from the start: datui -f -".to_string());
4005            return None;
4006        }
4007        if let Some(refusal) = crate::follow::refuse_paths(&paths, &options) {
4008            self.flash_note(refusal);
4009            return None;
4010        }
4011        let options = OpenOptions {
4012            follow: true,
4013            ..options
4014        };
4015        self.set_loading_phase("Scanning input", 10);
4016        self.name_what_is_loading(paths[0].clone());
4017        Some(AppEvent::Open(paths, options))
4018    }
4019
4020    /// Drop an expired flash. Returns true when the frame must redraw.
4021    pub fn tick_flash(&mut self) -> bool {
4022        if self.flash.as_ref().is_some_and(Flash::expired) {
4023            self.flash = None;
4024            return true;
4025        }
4026        false
4027    }
4028
4029    /// Show the next Polars user warning on the control bar, once per session, when the
4030    /// bar is free. Returns true when the frame must redraw.
4031    pub fn flash_polars_warning(&mut self) -> bool {
4032        if !self.bar_is_free() {
4033            return false;
4034        }
4035        match logging::next_polars_warning() {
4036            Some(warning) => {
4037                self.flash_note(format!("Polars: {warning}"));
4038                true
4039            }
4040            None => false,
4041        }
4042    }
4043
4044    /// Say that a background thread panicked when nothing else did; a job's panic ends
4045    /// the job instead, the way its error would. Returns true when the frame must
4046    /// redraw.
4047    pub fn flash_background_panic(&mut self) -> bool {
4048        if !self.bar_is_free() {
4049            return false;
4050        }
4051        match logging::take_unreported_panic() {
4052            Some(message) => {
4053                self.flash_note(message);
4054                true
4055            }
4056            None => false,
4057        }
4058    }
4059
4060    /// Whether a flash would be seen: a busy message outranks it, and a modal would
4061    /// hide it until it expired.
4062    fn bar_is_free(&self) -> bool {
4063        !self.is_busy()
4064            && self.flash.is_none()
4065            && !self.error_modal.active
4066            && !self.confirmation_modal.active
4067    }
4068
4069    /// See the `input_dropped` field.
4070    pub fn set_input_dropped(&mut self, dropped: bool) {
4071        self.input_dropped = dropped;
4072    }
4073
4074    /// The escapes that act at once while busy and jump ahead of anything queued: Ctrl-Q
4075    /// and Ctrl-C quit, Ctrl-O goes home, so a slow load never
4076    /// traps the user; a confirmation modal keeps its keys so it can be answered; and the
4077    /// home screen is never busy on its own account (only work left running behind it sets
4078    /// `busy`), so it keeps every key.
4079    pub fn hard_escape_while_busy(&self, key: &KeyEvent) -> bool {
4080        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
4081        let quit = ctrl && matches!(key.code, KeyCode::Char('q' | 'c'));
4082        let home = ctrl && key.code == KeyCode::Char('o');
4083        let cancel_analysis = self.analysis_modal.active
4084            && self.analysis_modal.computing.is_some()
4085            && key.code == KeyCode::Esc;
4086        let cancel_pivot = self.pivot_computing() && key.code == KeyCode::Esc;
4087        let leave_quality_evidence = self.quality_evidence_return.is_some()
4088            && self.input_mode == InputMode::Normal
4089            && key.code == KeyCode::Esc;
4090        let cancel_view = key.code == KeyCode::Esc && self.view_applying();
4091        let cancel_find = key.code == KeyCode::Esc && self.finding();
4092        let stop_sample =
4093            key.code == KeyCode::Esc && self.sample_drawing() && self.in_normal_table_view();
4094        // The help reads nothing, so it can always be closed, a load's screen included.
4095        let close_help = self.help.is_open()
4096            && matches!(key.code, KeyCode::Esc | KeyCode::F(1) | KeyCode::Char('?'));
4097        quit || home
4098            || close_help
4099            || cancel_analysis
4100            || cancel_pivot
4101            || cancel_view
4102            || cancel_find
4103            || stop_sample
4104            || leave_quality_evidence
4105            || self.confirmation_modal.active
4106            || self.input_mode == InputMode::Home
4107    }
4108
4109    /// The table's column cursor keys: `h` `l` (←→) a column, `[` `]` (or Shift+←→)
4110    /// a page of columns, `{` `}` the first and last. Never with Ctrl or Alt: Ctrl+[
4111    /// is Esc on a terminal.
4112    fn column_cursor_key(key: &KeyEvent) -> Option<crate::widgets::column_paging::CursorMove> {
4113        use crate::widgets::column_paging::CursorMove;
4114        if key
4115            .modifiers
4116            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
4117        {
4118            return None;
4119        }
4120        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
4121        match key.code {
4122            KeyCode::Left if shift => Some(CursorMove::PageLeft),
4123            KeyCode::Right if shift => Some(CursorMove::PageRight),
4124            KeyCode::Left | KeyCode::Char('h') => Some(CursorMove::Left),
4125            KeyCode::Right | KeyCode::Char('l') => Some(CursorMove::Right),
4126            KeyCode::Char('{') => Some(CursorMove::First),
4127            KeyCode::Char('}') => Some(CursorMove::Last),
4128            _ => None,
4129        }
4130    }
4131
4132    /// Whether a key may act while the app is busy. `App::handle` gates on this; the main
4133    /// loop applies the extra "nothing queued" condition for the second group.
4134    ///
4135    /// The hard escapes always qualify. Beyond them, in the plain Normal-mode table view
4136    /// (no text field, no modal), the harmless view keys act — quit, the column cursor
4137    /// and help — because the first key held in that view cannot be part of a typed
4138    /// `/query`. Harmless means reads nothing: the column cursor re-slices the buffer
4139    /// it already holds through `rescroll_columns`, never `collect`, which counts the rows
4140    /// when the count has not landed. Admitting a key that can count would put a
4141    /// metadata read per object of a cloud hive on this very thread —
4142    /// `a_key_that_acts_while_busy_reads_nothing` holds the line. Everything else,
4143    /// letters included, is type-ahead and waits; a bare Enter or Esc there confirms
4144    /// nothing and is dropped by the caller. Nothing is classified by keycode alone:
4145    /// the `h` in a typed `/hello` never scrolls.
4146    pub fn key_acts_while_busy(&self, key: &KeyEvent) -> bool {
4147        if self.hard_escape_while_busy(key) || self.menu_takes(key) {
4148            return true;
4149        }
4150        if self.key_acts_while_sampling(key) {
4151            return true;
4152        }
4153        if !self.in_normal_table_view() {
4154            return false;
4155        }
4156        // One row up or down inside the rows held, while all that is awaited is more
4157        // rows (#646): the table on screen is the one they are for.
4158        let step = match key.code {
4159            KeyCode::Down | KeyCode::Char('j') => Some(1),
4160            KeyCode::Up | KeyCode::Char('k') => Some(-1),
4161            _ => None,
4162        };
4163        if let Some(step) = step {
4164            return !self.busy
4165                && !self.loading.waits()
4166                && self
4167                    .jobs
4168                    .keys_held_only_by(|job| matches!(job, Job::Rows(_) | Job::OwedRows { .. }))
4169                && self
4170                    .data_table_state
4171                    .as_ref()
4172                    .is_some_and(|s| !s.scroll_would_trigger_collect(step));
4173        }
4174        matches!(
4175            key.code,
4176            KeyCode::Char('q')
4177                    | KeyCode::Char('Q')
4178                    // Drawn from what the table holds: row numbers, digit grouping, the
4179                    // type row (#646), a column's width (#647).
4180                    | KeyCode::Char('#')
4181                    | KeyCode::Char('<')
4182                    | KeyCode::Char('>')
4183                    | KeyCode::Char('=')
4184                    | KeyCode::Char('w')
4185                    | KeyCode::Char(',')
4186                    | KeyCode::Char('D')
4187                    | KeyCode::Left
4188                    | KeyCode::Right
4189                    | KeyCode::Char('h')
4190                    | KeyCode::Char('l')
4191                    | KeyCode::Char('{')
4192                    | KeyCode::Char('}')
4193                    | KeyCode::F(1)
4194                    | KeyCode::Char('?')
4195        )
4196    }
4197
4198    /// The plain table view: Normal mode with no help overlay, modal, or in-view modal
4199    /// (view, analysis) or context menu drawn over it.
4200    pub fn in_normal_table_view(&self) -> bool {
4201        self.input_mode == InputMode::Normal
4202            && !self.help.is_open()
4203            && !self.view_modal.active
4204            && !self.analysis_modal.active
4205            && !self.error_modal.active
4206            && !self.confirmation_modal.active
4207            && self.context_menu.is_none()
4208    }
4209
4210    /// While a header is dragged over another column, a rule on the header where it
4211    /// would land: after that column when it moves right, before it when left.
4212    fn render_drop_mark(&self, buf: &mut Buffer, ctx: &crate::render::context::RenderContext) {
4213        let Some(pointer::Drag::Move { column, over }) = self.pointer.drag() else {
4214            return;
4215        };
4216        let Some(state) = self.data_table_state.as_ref() else {
4217            return;
4218        };
4219        let Some((header, columns)) = state.drawn_header() else {
4220            return;
4221        };
4222        let order = state.get_column_order();
4223        let (Some(from), Some(to)) = (
4224            order.iter().position(|c| c == column),
4225            order.iter().position(|c| c == over),
4226        ) else {
4227            return;
4228        };
4229        // Only where a drop would land: frozen among frozen, scrolling among scrolling.
4230        let locked = state.locked_columns_count().min(order.len());
4231        if from == to || (from < locked) != (to < locked) {
4232            return;
4233        }
4234        let Some((left, right, _)) = columns.iter().find(|(_, _, name)| name == over) else {
4235            return;
4236        };
4237        let x = if to > from {
4238            *right
4239        } else {
4240            left.saturating_sub(1)
4241        };
4242        if !(header.x..header.right()).contains(&x) {
4243            return;
4244        }
4245        let g = crate::glyphs::get();
4246        for y in header.y..header.bottom() {
4247            let cell = &mut buf[(x, y)];
4248            cell.set_symbol(g.rule);
4249            cell.set_style(Style::default().fg(ctx.accent));
4250        }
4251    }
4252
4253    /// The context menu is open over the plain table view, nothing over it.
4254    pub(crate) fn menu_showing(&self) -> bool {
4255        self.context_menu.is_some()
4256            && self.input_mode == InputMode::Normal
4257            && self.data_table_state.is_some()
4258            && !self.help.is_open()
4259            && !self.view_modal.active
4260            && !self.analysis_modal.active
4261            && !self.error_modal.active
4262            && !self.confirmation_modal.active
4263    }
4264
4265    /// Whether `key` is one the open menu answers itself (moving, choosing, closing),
4266    /// which reads nothing and so acts while busy.
4267    pub(crate) fn menu_takes(&self, key: &KeyEvent) -> bool {
4268        self.menu_showing()
4269            && key.modifiers.is_empty()
4270            && matches!(
4271                key.code,
4272                KeyCode::Up
4273                    | KeyCode::Down
4274                    | KeyCode::Char('j')
4275                    | KeyCode::Char('k')
4276                    | KeyCode::Enter
4277                    | KeyCode::Esc
4278            )
4279    }
4280
4281    /// Open the context menu at `at`, over the cell the cursor was just put on.
4282    pub fn open_context_menu(&mut self, at: ratatui::layout::Position) {
4283        // A datetime is made from text, a date or a time.
4284        let combine = self
4285            .data_table_state
4286            .as_ref()
4287            .and_then(|state| {
4288                let column = state.current_column()?;
4289                state.schema().get(column).cloned()
4290            })
4291            .is_some_and(|dtype| {
4292                matches!(dtype, DataType::String | DataType::Date | DataType::Time)
4293            });
4294        self.context_menu = Some(context_menu::ContextMenu::with(
4295            at,
4296            context_menu::column_items(combine),
4297        ));
4298    }
4299
4300    /// Close the context menu, if it is open.
4301    pub fn close_context_menu(&mut self) {
4302        self.context_menu = None;
4303    }
4304
4305    /// The line `i` of the open menu, chosen: the menu closes and its key is
4306    /// pressed, offered as typed.
4307    pub fn choose_from_menu(&mut self, i: usize) -> Option<AppEvent> {
4308        let menu = self.context_menu.take()?;
4309        match menu.chosen(i)? {
4310            context_menu::MenuKey::Run(key) => Some(AppEvent::Press(key)),
4311            context_menu::MenuKey::Do(action) => {
4312                self.menu_action(action);
4313                None
4314            }
4315            _ => None,
4316        }
4317    }
4318
4319    /// A header dropped on another column: the order with `column` moved to where
4320    /// `onto` is, as `H` / `L` would leave it pressed that many times. A frozen column
4321    /// moves among the frozen ones only, and a scrolling one among the scrolling.
4322    pub fn drop_column(&mut self, column: &str, onto: &str) -> Option<AppEvent> {
4323        let state = self.data_table_state.as_ref()?;
4324        let mut order = state.headers();
4325        let locked = state.locked_columns_count().min(order.len());
4326        let from = order.iter().position(|c| c == column)?;
4327        let to = order.iter().position(|c| c == onto)?;
4328        if from == to || (from < locked) != (to < locked) {
4329            return None;
4330        }
4331        self.flash = None;
4332        self.data_table_state.as_mut()?.set_current_column(column);
4333        let moving = order.remove(from);
4334        order.insert(to, moving);
4335        // The sidebar places hidden columns by the order it last applied; the column
4336        // moves there too, so the shown order agrees with the table (a hidden one may
4337        // sit otherwise than repeated H / L would leave it).
4338        let applied = &mut self.sort_filter_modal.sort.applied_order;
4339        if let (Some(i), Some(j)) = (
4340            applied.iter().position(|c| c == column),
4341            applied.iter().position(|c| c == onto),
4342        ) {
4343            let moving = applied.remove(i);
4344            applied.insert(j, moving);
4345        }
4346        Some(AppEvent::ColumnOrder(order, locked))
4347    }
4348
4349    /// Whether a text field currently owns typed characters, so the wheel and `?` leave
4350    /// it alone. The home filter is deliberately excluded.
4351    pub fn text_field_focused(&self) -> bool {
4352        match self.input_mode {
4353            InputMode::Editing => true,
4354            InputMode::Export => matches!(
4355                self.export_modal.focus,
4356                ExportFocus::PathInput | ExportFocus::CsvDelimiter
4357            ),
4358            // The Picker narrows by typing, so it types.
4359            InputMode::Copy => self.copy_modal.picker.is_some(),
4360            // The find line types.
4361            InputMode::Inspect => self.inspector_modal.finding,
4362            // The Picker narrows by typing, so it types.
4363            InputMode::GoToColumn => true,
4364            InputMode::PickFormat | InputMode::Retype => true,
4365            InputMode::Combine => self
4366                .combine
4367                .as_ref()
4368                .is_some_and(|c| c.picker.is_some() || c.focus == retype_modal::CombineField::Name),
4369            InputMode::PickTable => true,
4370            InputMode::Sample => self
4371                .sample_form
4372                .as_ref()
4373                .is_some_and(|form| form.field.is_text()),
4374            // The whole inline editor types (pickers narrow, the value edits), as
4375            // do the add-sort Picker and the Columns tab's find.
4376            InputMode::SortFilter => self.sort_filter_modal.typing(),
4377            InputMode::PivotMelt => {
4378                // The Picker narrows by typing, so it types too.
4379                self.pivot_melt_modal.picker.is_some()
4380                    || self
4381                        .pivot_melt_modal
4382                        .is_text_row(self.pivot_melt_modal.focus)
4383            }
4384            InputMode::Chart => {
4385                if self.chart_export_modal.active {
4386                    self.chart_export_modal
4387                        .input(self.chart_export_modal.focus)
4388                        .is_some()
4389                } else {
4390                    // The open column Picker narrows by typing, so it types.
4391                    self.chart_modal.picker.is_some()
4392                }
4393            }
4394            InputMode::Normal => {
4395                self.analysis_modal.sample_scope_typing()
4396                    || self.analysis_modal.quality_expected_typing()
4397                    || self.analysis_modal.intent_typing()
4398                    || self.analysis_modal.export_typing()
4399                    || (self.view_modal.active
4400                        && self.view_modal.mode != ViewModalMode::List
4401                        && matches!(
4402                            self.view_modal.form_focus,
4403                            FormFocus::Name
4404                                | FormFocus::Description
4405                                | FormFocus::ExactPath
4406                                | FormFocus::RelativePath
4407                                | FormFocus::PathPattern
4408                                | FormFocus::FilenamePattern
4409                        ))
4410            }
4411            InputMode::Home | InputMode::Info | InputMode::ValueCounts => false,
4412            // The prompt types, and so does the spec picker's filter.
4413            InputMode::Hex => self
4414                .hex
4415                .as_ref()
4416                .is_some_and(|view| view.prompt.is_some() || view.picker.is_some()),
4417        }
4418    }
4419
4420    pub fn send_event(&mut self, event: AppEvent) -> Result<()> {
4421        self.events.send(event)?;
4422        Ok(())
4423    }
4424
4425    /// Whether the dataset on screen is one that opened before its footers were read
4426    /// and is still waiting for them.
4427    ///
4428    /// Not the same question as whether a pass is running. The counter is shared with
4429    /// every open, and abandoning one does not stop it: without this, giving up on a
4430    /// large local directory and going back to the dataset you had would leave that
4431    /// dataset's control bar counting footers belonging to the directory you left.
4432    /// Whether the row count on the control bar is on its way, so a spinner stands in
4433    /// for it. Asked by the bar, and by the run loop, which turns the spinner: the
4434    /// two disagreed while a dataset read its own footers, and the spinner sat still.
4435    pub fn row_count_pending(&self) -> bool {
4436        // A load in flight counts as pending: the number `data_table_state` still holds
4437        // belongs to the dataset being replaced, and printing it beside the incoming
4438        // file's name would read as the new one's.
4439        // A dataset still reading its own footers counts too: it declines the ordinary
4440        // count because that pass is bringing one, so nothing is "in flight" — and the
4441        // number it holds meanwhile is only as far as the buffer reaches. Printed
4442        // plainly, a prefix of six thousand files reads `Rows: 70`.
4443        self.len_count_inflight.is_some()
4444            || self.loading.awaiting_dataset()
4445            // A re-read owed to a dataset whose footers could not be read is a count
4446            // that is coming: the collect it is waiting to run is what starts one. The
4447            // dataset has already stopped saying it counts itself later (it gave up on
4448            // the pass the moment that pass failed), so without this the bar falls
4449            // through to printing the number it happens to hold — which is only as far
4450            // as the buffer reached. A prefix of six thousand files reads `Rows: 70`,
4451            // plainly, for as long as the work in front of the errand takes.
4452            || self.reread_owed.is_some()
4453            || self
4454                .data_table_state
4455                .as_ref()
4456                .is_some_and(|state| state.counts_itself_later())
4457    }
4458
4459    /// Whether a spinner is on screen, so the run loop turns it and redraws.
4460    pub fn something_is_spinning(&self) -> bool {
4461        self.is_busy()
4462            || (self.row_count_pending() && !self.awaiting_open_confirmation())
4463            // The clock beside "source read finishing" keeps time until it has.
4464            || (self.analysis_modal.active && self.cancelled_analysis_running().is_some())
4465            || self.chart_preparing()
4466            || self.value_counts_computing()
4467            || (self.input_mode == InputMode::Home
4468                && (self.home.awaiting_listing().is_some()
4469                    || self.home.sections_waiting()
4470                    || !self.home.peeking.is_empty()))
4471    }
4472
4473    fn dataset_is_still_reading_its_footers(&self) -> bool {
4474        self.data_table_state
4475            .as_ref()
4476            .is_some_and(|state| state.footers_pending().is_some())
4477    }
4478
4479    /// Take the numbers the whole frame will be drawn from.
4480    ///
4481    /// Only one so far: the footer count. It is read here rather than where it is
4482    /// shown because two parts of the screen show it, they are painted at different
4483    /// moments, and a background thread is moving it between them.
4484    fn begin_frame(&mut self) {
4485        // Back from home to the table whose lines were being indexed.
4486        if self.indexing_paused && self.input_mode != InputMode::Home {
4487            self.index_lines();
4488        }
4489        self.footers_this_frame = self.footer_progress().reading();
4490        self.listed_this_frame = self.footer_progress().listed();
4491        // Whatever this frame does not draw cannot be clicked.
4492        self.pointer.forget_drawn();
4493        if let Some(state) = self.data_table_state.as_mut() {
4494            state.forget_drawn();
4495        }
4496    }
4497
4498    /// The footer counter the screen reads: the open's own while one is on its way to
4499    /// a dataset, else the dataset on screen's. Each open counts on its own, so one
4500    /// replaced half way through cannot count under the name of the file that replaced
4501    /// it.
4502    pub fn footer_progress(&self) -> &Arc<crate::schema_union::FooterProgress> {
4503        self.loading.progress().unwrap_or(&self.footer_progress)
4504    }
4505
4506    /// Hold past the app: dropped after it, it removes the temp files the app's opens
4507    /// were still writing, giving their workers up to a second to stop first. Without
4508    /// it, a quit mid-download or mid-decompression can end the process before the
4509    /// worker removes its partial file.
4510    pub fn exit_sweep(&self) -> ExitSweep {
4511        ExitSweep(self.loading.unfinished().clone())
4512    }
4513
4514    /// Whether an open is on its way and its dataset not installed yet: whatever table
4515    /// `data_table_state` holds meanwhile belongs to the dataset being replaced, so the
4516    /// main view shows the open's progress instead of it.
4517    pub(crate) fn awaiting_dataset(&self) -> bool {
4518        self.loading.awaiting_dataset()
4519    }
4520
4521    /// What the loading screen and the control bar say about the open in flight: its
4522    /// phase, the flat percentage beside it, the path it names and that path's size.
4523    pub(crate) fn load_shown(&self) -> Option<(&str, u16, Option<&Path>, u64)> {
4524        self.loading.current().map(|load| {
4525            let (phase, percent) = load.phase().label();
4526            (phase, percent, load.path(), load.size())
4527        })
4528    }
4529
4530    /// What the load is doing, for whichever part of the screen is saying so.
4531    ///
4532    /// The footer count stands in for the phase while a pass is running: it says the
4533    /// same thing and says how far along it is. Both callers read it from
4534    /// [`Self::footers_this_frame`], one number taken once a frame, so they cannot say
4535    /// two different things about one wait.
4536    pub(crate) fn loading_phase<'a>(&self, phase: &'a str) -> std::borrow::Cow<'a, str> {
4537        match self.footers_this_frame {
4538            Some((read, total)) => std::borrow::Cow::Owned(format!(
4539                "Reading footers: {} of {}",
4540                crate::numfmt::group_chrome(read),
4541                crate::numfmt::group_chrome(total)
4542            )),
4543            // A listing has no total to count towards, so it says how far it has got.
4544            None => match self.listed_this_frame {
4545                Some(listed) => std::borrow::Cow::Owned(format!(
4546                    "Listing files: {}",
4547                    crate::numfmt::group_chrome(listed)
4548                )),
4549                None => std::borrow::Cow::Borrowed(phase),
4550            },
4551        }
4552    }
4553
4554    /// An open is on its way: the loading screen takes over now, saying `phase`, and keys
4555    /// wait for it. Called before the event that carries the open out — by `run` before
4556    /// the first frame, and by a key before the `Open` it returns — because a frame is
4557    /// drawn between the two and would otherwise show the outgoing dataset.
4558    pub fn set_loading_phase(&mut self, phase: impl Into<String>, progress_percent: u16) {
4559        self.announce_open(false, phase.into(), progress_percent);
4560    }
4561
4562    /// As [`Self::set_loading_phase`], for an open chosen on the home screen when
4563    /// `from_home`: that is where its failure is reported.
4564    fn announce_open(&mut self, from_home: bool, phase: String, percent: u16) {
4565        self.make_way_for_an_open();
4566        self.loading.announce(from_home, phase, percent);
4567    }
4568
4569    /// Put the path on the loading screen, so a wait says what it is waiting for.
4570    pub(crate) fn name_what_is_loading(&mut self, path: PathBuf) {
4571        self.loading.name(path);
4572    }
4573
4574    /// An open is being asked for: a load already doing work is put down for it, unless
4575    /// it has not started any (the look or the frame that leads to this open).
4576    fn make_way_for_an_open(&mut self) {
4577        if let Some(retired) = self.loading.make_way() {
4578            self.put_down_load(retired);
4579        }
4580    }
4581
4582    /// An open has its request: make way for it, and stop what the dataset on screen
4583    /// was still reading for itself.
4584    fn begin_new_dataset(&mut self) {
4585        self.make_way_for_an_open();
4586        // A preview's dataset this open did not take is a page nobody is opening.
4587        self.home_previews.drop_prepared();
4588        self.reset_chart_state();
4589        self.jobs.advance();
4590        // The dataset's footer pass is no longer wanted, and unread, unpaid-for is better
4591        // than read and dropped. The open counts its own footers on a counter of its
4592        // own, which the dataset takes over if it installs.
4593        //
4594        // The meter needs no equivalent: it belongs to the dataset rather than to the
4595        // app, so a load that never reaches the screen never has one installed. See
4596        // `DataTableState::measurements`.
4597        self.footer_progress.cancel();
4598        *self
4599            .pending_footers_result
4600            .lock()
4601            .unwrap_or_else(|e| e.into_inner()) = None;
4602    }
4603
4604    /// Put down what the app keeps for a load the loader has retired: its jobs, whose
4605    /// answers are for a screen nobody is on, their lines on the control bar, and the
4606    /// question about its download.
4607    fn put_down_load(&mut self, retired: loading::Retired) {
4608        let id = retired.id;
4609        let lines = self.jobs.quiet(|job| job.load() == Some(id));
4610        self.jobs.supersede(|job| job.load() == Some(id));
4611        if self
4612            .status_message
4613            .as_ref()
4614            .is_some_and(|status| lines.contains(status))
4615        {
4616            self.status_message = None;
4617        }
4618        if retired.asking {
4619            self.confirmation_modal.hide();
4620        }
4621    }
4622
4623    /// Carry out what the open needs next.
4624    fn run_load_step(&mut self, step: loading::Step) -> Option<AppEvent> {
4625        use loading::Step;
4626        let load = self.loading.id();
4627        match step {
4628            Step::Nothing => None,
4629            Step::Crash(message) => Some(AppEvent::Crash(message)),
4630            Step::Failed(failed) => {
4631                self.load_failed(failed);
4632                None
4633            }
4634            Step::Tables(tables) => {
4635                self.land_on_tables(tables);
4636                None
4637            }
4638            Step::Hex(hex) => {
4639                self.land_on_hex(hex);
4640                None
4641            }
4642            Step::Install(loaded) => {
4643                // The view an open applies reads its own first rows, so the dataset's are
4644                // not read.
4645                if self.install_dataset(*loaded) {
4646                    return None;
4647                }
4648                #[cfg(test)]
4649                {
4650                    self.first_rows_asked += 1;
4651                }
4652                if !self.spawn_async_collect(Self::LOADING_BUFFER) {
4653                    // Nothing to read: the buffer already serves the view.
4654                    if self.status_message.as_deref() == Some(Self::LOADING_BUFFER) {
4655                        self.status_message = None;
4656                    }
4657                    self.first_rows_settled();
4658                }
4659                None
4660            }
4661            #[cfg(any(feature = "http", feature = "cloud"))]
4662            Step::Ask(pending) => {
4663                // Nothing runs while the question is up: datui waits on a key, and a
4664                // spinner would read as progress. The loader holds the generation
4665                // meanwhile.
4666                self.confirmation_modal
4667                    .show(Self::download_confirmation_message(
4668                        &pending,
4669                        self.loading.download_note(),
4670                    ));
4671                None
4672            }
4673            Step::AskRead(read) => {
4674                // As for a download: nothing runs, and the generation is held.
4675                self.loading.hold_while_asking(self.jobs.hold());
4676                self.confirmation_modal
4677                    .show(Self::in_memory_confirmation_message(&read));
4678                None
4679            }
4680            step => {
4681                let load = load.expect("a step that runs work belongs to the open in flight");
4682                self.spawn_load_phase(load, step);
4683                None
4684            }
4685        }
4686    }
4687
4688    /// Keep the shape of a downloaded dataset under the URL it was opened from, once its
4689    /// rows are counted: nothing lists a web file, so this is the only way its recent,
4690    /// and its catalog row, can say `344 × 9` (#547 D12). Once per dataset.
4691    fn remember_a_downloads_shape(&mut self) {
4692        if self.shape_remembered == Some(self.dataset_generation) {
4693            return;
4694        }
4695        let Some(url) = self.path.clone().filter(|p| source::is_remote_url(p)) else {
4696            return;
4697        };
4698        let Some(state) = self.data_table_state.as_ref().filter(|s| s.fetched()) else {
4699            return;
4700        };
4701        let Some(rows) = state.num_rows_if_valid().filter(|_| !state.changes_rows()) else {
4702            return;
4703        };
4704        self.shape_remembered = Some(self.dataset_generation);
4705        let columns: Vec<String> = state
4706            .source_schema()
4707            .iter_names()
4708            .map(|name| name.to_string())
4709            .collect();
4710        let facts = crate::cache::DatasetFacts {
4711            mtime: std::time::SystemTime::now()
4712                .duration_since(std::time::UNIX_EPOCH)
4713                .map(|d| d.as_secs())
4714                .unwrap_or_default(),
4715            size: 0,
4716            rows: Some(rows),
4717            cols: Some(columns.len()),
4718            cols_sampled: false,
4719            columns,
4720            kind: Some(discover::EntryKind::File),
4721            classified_by: discover::CLASSIFIER_VERSION,
4722            cost: Default::default(),
4723            holds: Default::default(),
4724        };
4725        // Off the UI thread: the index takes a lock other instances may hold.
4726        let cache = self.cache.clone();
4727        self.cache_writes
4728            .spawn(move || cache.record_dataset_facts(&[(url, facts)]));
4729    }
4730
4731    /// The first rows of an open are on screen, or will not be read: its wait is over.
4732    fn first_rows_settled(&mut self) {
4733        self.loading.first_rows_settled();
4734    }
4735
4736    /// Read the rows on screen again, now that the frame they were read through has
4737    /// been replaced.
4738    ///
4739    /// The dataset's own errand, not its open's: this happens long after the open has
4740    /// finished, and after a glance at the home screen just the same. The join has
4741    /// already dropped the buffer, so nothing dropping this leaves the table with no
4742    /// rows to show at the moment it was to show more of them.
4743    fn reread_after_the_footers_joined(&mut self) {
4744        // Any re-read satisfies one that was owed: this is the collect the errand was
4745        // waiting to run, whoever asked for it.
4746        self.reread_owed = None;
4747        // End was pressed while the footers were still coming, and they are what the
4748        // end was waiting on. Taken either way: a flag left from a dataset that is gone
4749        // is not this one's to act on. The jump reads the page it lands on, so reading
4750        // the page here first would be one fetched to be thrown away.
4751        if self.end_when_the_footers_land.take() == Some(self.dataset_generation) {
4752            self.status_message = None;
4753            if let Some(next) = self.jump_key(AppEvent::DoScrollEnd) {
4754                // The jump reads the page it lands on, so reading this one first would
4755                // be a page fetched to be thrown away.
4756                let _ = self.events.send(next);
4757                return;
4758            }
4759            // Unless it asked for no read: the view was already at the end, or the pass
4760            // brought no count and the jump is waiting on the ordinary one. The join has
4761            // dropped the buffer either way, so falling through is the difference
4762            // between a table and an empty one.
4763        }
4764        self.spawn_async_collect(Self::LOADING_BUFFER);
4765    }
4766
4767    /// Run a buffer collect that was asked for while other work was waiting on the
4768    /// generation.
4769    ///
4770    /// The same shape as `reread_when_the_work_allows` below, and for the same reason:
4771    /// the collect bumps `task_generation`, so it waits its turn and is tried again
4772    /// after every event.
4773    fn collect_when_the_work_allows(&mut self) {
4774        let Some(&Job::OwedRows { dataset, .. }) = self.jobs.owed(Self::owed_rows) else {
4775            return;
4776        };
4777        if dataset != self.dataset_generation {
4778            // The dataset it was owed to is gone, and so is the view it was filling.
4779            // Only the errand is put down, and the keys it held with it; the status line
4780            // belongs to whatever replaced the dataset.
4781            self.jobs.take_owed(Self::owed_rows);
4782            return;
4783        }
4784        if self.work_a_bump_would_strand() {
4785            return;
4786        }
4787        let Some(Job::OwedRows { status, .. }) = self.jobs.take_owed(Self::owed_rows) else {
4788            return;
4789        };
4790        if !self.spawn_async_collect(&status) {
4791            self.busy = false;
4792            self.status_message = None;
4793            // The collect that was owed may have been an open's first rows. Left waiting
4794            // on them, the bar would read "Loading buffer... 70%" with the app idle for
4795            // the rest of the session.
4796            self.first_rows_settled();
4797        }
4798    }
4799
4800    /// Run the re-read a failed footer pass owes the dataset, once it can be run
4801    /// without throwing another answer away.
4802    ///
4803    /// The failure branch of `BackgroundFootersJoined` used to re-read on the spot,
4804    /// which bumped `task_generation` with no check at all — the one path into the
4805    /// collect that never asked `work_the_join_would_cancel`. An export in its collect
4806    /// phase then never wrote its file and said nothing about it. So the errand waits
4807    /// its turn, the way held columns already do.
4808    fn reread_when_the_work_allows(&mut self) {
4809        let Some(generation) = self.reread_owed else {
4810            return;
4811        };
4812        if generation != self.dataset_generation {
4813            // The dataset it was owed to is gone; so is the errand.
4814            self.reread_owed = None;
4815            return;
4816        }
4817        if self.work_the_join_would_cancel() {
4818            return;
4819        }
4820        self.reread_after_the_footers_joined();
4821    }
4822
4823    /// Retire an End that was waiting on a count which can no longer answer it.
4824    ///
4825    /// Only the flag and the message it put up: the jump itself is not re-issued. See
4826    /// the caller in `BackgroundLenReady` for why asking again is the wrong repair.
4827    fn retire_the_end_that_was_waiting(&mut self) {
4828        self.end_after_count = None;
4829        self.take_down_the_counting_status();
4830    }
4831
4832    /// Take down "Counting rows to find the end...", and only that.
4833    ///
4834    /// Clearing the status outright would wipe whatever else is using the line — a
4835    /// load's phase, an export's progress — on behalf of a key pressed somewhere else.
4836    fn take_down_the_counting_status(&mut self) {
4837        if matches!(
4838            self.status_message.as_deref(),
4839            Some(Self::COUNTING_FOR_END | Self::INDEXING_FOR_ROW)
4840        ) {
4841            self.status_message = None;
4842        }
4843    }
4844
4845    /// What the status line says while `:N` waits for the lines to be indexed.
4846    const INDEXING_FOR_ROW: &'static str = "Reading lines to find the row...";
4847
4848    /// What the status line says while an End is waiting on a row count. Named so the
4849    /// paths that retire such an End can take the message back down without reaching
4850    /// for a literal, and without clearing a message that belongs to something else.
4851    const COUNTING_FOR_END: &'static str = "Counting rows to find the end...";
4852
4853    /// What the control bar says while a path is being looked at. Named so the answer can
4854    /// take down its own line without clearing one that belongs to something else.
4855    const LOOKING: &'static str = "Looking...";
4856
4857    /// The wait while a directory named on the command line is looked at: which files it
4858    /// holds, and whether they are one table. Seconds, for a directory of large Parquet.
4859    pub const LOOKING_AT_A_DIRECTORY: &'static str = "Looking at the directory";
4860
4861    /// The wait while the rows for the view are fetched.
4862    pub const LOADING_BUFFER: &'static str = "Loading buffer...";
4863
4864    /// The wait while a view's pivot or first rows are read.
4865    const APPLYING_VIEW: &'static str = "Applying view...";
4866
4867    /// The wait while a grouped row the buffer does not hold is read to drill into.
4868    const READING_GROUP: &'static str = "Reading the group...";
4869
4870    /// The wait while the inspector reads a row's hidden and binary fields.
4871    const READING_FIELDS: &'static str = "Reading fields...";
4872
4873    /// Above this a field is copied off the UI thread: a long list's JSON can take
4874    /// a moment to write.
4875    const FIELD_COPY_INLINE_BYTES: usize = 1024 * 1024;
4876    /// The most JSON a copy of a JSON value writes where the clipboard sets no cap.
4877    const JSON_COPY_MAX_BYTES: usize = 64 * 1024 * 1024;
4878    /// The wait while the inspector parses long text as JSON.
4879    const READING_JSON: &'static str = "Reading JSON...";
4880
4881    /// The wait while a pivot reads the view.
4882    const COMPUTING_PIVOT: &'static str = "Computing pivot...";
4883
4884    /// How long a fetch goes unmentioned. A local page lands well inside it, and the key
4885    /// chips staying put is the difference between paging and a bar that blinks a
4886    /// sentence on every screen.
4887    const A_FETCH_WORTH_SAYING: std::time::Duration = std::time::Duration::from_millis(300);
4888
4889    /// Grow the buffer before the view reaches its end, rather than once it has.
4890    ///
4891    /// Nothing waits on it: no `busy`, no message, and keys go on paging through the
4892    /// rows on hand. One at a time — a scroll that outruns it either waits on it, when it
4893    /// is bringing the rows asked for, or supersedes it by the generation, as any newer
4894    /// collect does. Never when a bump would strand other work.
4895    fn load_ahead(&mut self) {
4896        // The generation is asked about before the position is marked asked. Held while
4897        // the app is idle (a download waiting on the user), a frame would otherwise
4898        // spend the position on that refusal and never ask again (#490).
4899        if self.is_busy()
4900            || self.jobs.owed(Self::owed_rows).is_some()
4901            || self.rows_in_flight().is_some()
4902            || self.work_a_bump_would_strand()
4903        {
4904            return;
4905        }
4906        let Some(state) = self.data_table_state.as_ref() else {
4907            return;
4908        };
4909        // Asked of each position once. Planning can give back the buffer on hand — a
4910        // row group too large to add under the caps — and asking again every frame
4911        // would plan it again every frame.
4912        let position = state.buffer_position();
4913        if !state.wants_to_load_ahead() || self.loaded_ahead_from == Some(position) {
4914            return;
4915        }
4916        self.loaded_ahead_from = Some(position);
4917        self.spawn_collect(None);
4918    }
4919
4920    /// Whether the bar is still keeping quiet about a fetch for the view.
4921    fn fetch_too_young_to_mention(&self) -> bool {
4922        self.status_message.as_deref() == Some(Self::LOADING_BUFFER)
4923            && self
4924                .rows_in_flight()
4925                .is_some_and(|inflight| inflight.began.elapsed() < Self::A_FETCH_WORTH_SAYING)
4926    }
4927
4928    /// Work already running that the re-read after a join would cancel.
4929    ///
4930    /// The re-read goes through the ordinary collect, which bumps `task_generation`, so
4931    /// everything a bump would strand has to be done first — and that is
4932    /// [`Jobs::would_strand`]'s job now, rather than a list of the kinds of work that
4933    /// might be running.
4934    ///
4935    /// One thing more than a bump, though: a join takes a fresh `len_generation` too. A
4936    /// chart is prepared against the frame rather than the generation
4937    /// (`BackgroundChartReady` carries no generation at all), so a bump cannot strand
4938    /// one but changing the frame under it can.
4939    fn work_the_join_would_cancel(&self) -> bool {
4940        self.work_a_bump_would_strand() || self.chart_preparing()
4941    }
4942
4943    /// Give the dataset what its footers found, if it can take it now.
4944    ///
4945    /// It cannot while the user is looking at a query, a pivot, a melt or a drill-down:
4946    /// those make their own result the root, and widening the scan underneath one takes
4947    /// away the columns it is built from. So the columns wait — held, not dropped — and
4948    /// this is tried again after every event, which is the cheapest way to catch the
4949    /// moment the view comes back to the data.
4950    ///
4951    /// Returns whether the dataset took them, so the caller can re-read the rows on
4952    /// screen through the wider frame.
4953    fn join_held_footers(&mut self) -> bool {
4954        let Some((generation, _)) = self.footers_held.as_ref() else {
4955            return false;
4956        };
4957        if *generation != self.dataset_generation {
4958            // The dataset they belong to is gone; so are they.
4959            self.footers_held = None;
4960            return false;
4961        }
4962        if self.data_table_state.is_none() || self.work_the_join_would_cancel() {
4963            return false;
4964        }
4965        let Some((generation, found)) = self.footers_held.take() else {
4966            return false;
4967        };
4968        let state = self
4969            .data_table_state
4970            .as_mut()
4971            .expect("checked just above, and nothing since takes it");
4972        // Whether this is the moment is the dataset's call, not this one's: it is the
4973        // frame on screen that knows whether it still grows from the scan.
4974        match state.join_dataset_schema(found) {
4975            Ok(()) => true,
4976            Err(found) => {
4977                self.footers_held = Some((generation, *found));
4978                false
4979            }
4980        }
4981    }
4982
4983    /// Start the pass that reads the rest of a staged open's footers.
4984    ///
4985    /// Not a job, which the user would wait on: the whole point of opening
4986    /// before every footer is read is that the dataset works while they are read. The
4987    /// generation is the dataset's rather than the task's, because a collect bumps the
4988    /// task's and this pass outlives several of them.
4989    fn start_pending_footers(&mut self) {
4990        let Some(join) = self
4991            .data_table_state
4992            .as_ref()
4993            .and_then(|state| state.footers_pending())
4994        else {
4995            return;
4996        };
4997        let generation = self.dataset_generation;
4998        let slot = self.pending_footers_result.clone();
4999        let tx = self.events.clone();
5000        let progress = self.footer_progress.clone();
5001        self.runtime.spawn_blocking(move || {
5002            // Reported either way. A pass that could not read them has to say so, or
5003            // the dataset waits for it for the rest of the session — and a waiting
5004            // dataset is one that will not count itself, because the count was what
5005            // the pass was bringing back. A pass that panicked could not read them.
5006            let found = logging::catch_panic(|| join(&progress)).unwrap_or(None);
5007            if !Self::record_footers(&slot, generation, found) {
5008                return;
5009            }
5010            let _ = tx.send(AppEvent::BackgroundFootersJoined { generation });
5011        });
5012    }
5013
5014    /// Index the rest of a text file's lines behind its first rows, and say when they
5015    /// are all in ([`AppEvent::LinesIndexed`]). Not a job, which the user would wait on:
5016    /// the table works meanwhile, and a read of every line waits for them on its own
5017    /// worker. The last dataset's indexing, if it is still going, stops.
5018    fn start_indexing(&mut self) {
5019        self.end_when_indexed = None;
5020        self.goto_when_indexed = None;
5021        self.index_lines();
5022    }
5023
5024    /// Run the indexing of the dataset on screen's lines, if they still have lines to
5025    /// index: a new dataset's, or one paused while home was up. Lines of a dataset no
5026    /// longer on screen stop for good, and the reads waiting on them give up.
5027    fn index_lines(&mut self) {
5028        use std::sync::atomic::Ordering;
5029        self.indexing_stop.store(true, Ordering::Relaxed);
5030        self.indexing_paused = false;
5031        let lines = self
5032            .data_table_state
5033            .as_ref()
5034            .and_then(|state| state.lines_to_index().cloned());
5035        if let Some(old) = self.indexing_lines.take()
5036            && lines.as_ref().is_none_or(|lines| !Arc::ptr_eq(lines, &old))
5037        {
5038            old.stop_indexing();
5039        }
5040        let Some(lines) = lines.filter(|lines| lines.resume_indexing()) else {
5041            return;
5042        };
5043        self.indexing_lines = Some(lines.clone());
5044        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
5045        self.indexing_stop = stop.clone();
5046        let generation = self.dataset_generation;
5047        let tx = self.events.clone();
5048        let waiting = lines.clone();
5049        let spawned = std::thread::Builder::new()
5050            .name("datui-index".to_string())
5051            .spawn(move || {
5052                loop {
5053                    // Paused or replaced: whoever stopped it says what becomes of the
5054                    // reads waiting on the lines.
5055                    if stop.load(Ordering::Relaxed) {
5056                        return;
5057                    }
5058                    // A panic stops it where it is: the rows so far are what there is,
5059                    // rather than a count that never comes.
5060                    let done =
5061                        logging::catch_panic(|| lines.index_more(INDEX_STEP)).unwrap_or(true);
5062                    if done {
5063                        lines.stop_indexing();
5064                        let rows = lines.rows();
5065                        let _ = tx.send(AppEvent::LinesIndexed { generation, rows });
5066                        return;
5067                    }
5068                }
5069            });
5070        // No thread to index them: the lines so far are what there is, and nothing
5071        // waits for more.
5072        if spawned.is_err() {
5073            waiting.stop_indexing();
5074            self.indexing_lines = None;
5075            if let Some(state) = self.data_table_state.as_mut() {
5076                state.lines_indexed(waiting.rows());
5077            }
5078        }
5079    }
5080
5081    /// Home is up: the indexing waits, the reads waiting on it with it, until the
5082    /// table is back ([`Self::begin_frame`]).
5083    fn pause_indexing(&mut self) {
5084        if self.indexing_lines.is_some() {
5085            self.indexing_stop
5086                .store(true, std::sync::atomic::Ordering::Relaxed);
5087            self.indexing_paused = true;
5088        }
5089    }
5090
5091    /// More of the dataset's lines are indexed: its frames take them, and once all are,
5092    /// its count and an End that waited for it.
5093    fn lines_indexed(&mut self, generation: u64, rows: usize) {
5094        if generation != self.dataset_generation {
5095            return;
5096        }
5097        let Some(state) = self.data_table_state.as_mut() else {
5098            return;
5099        };
5100        self.indexing_lines = None;
5101        if !state.lines_indexed(rows) {
5102            // Set aside while the lines finished (the quality evidence view): they
5103            // land on the dataset that comes back.
5104            if let Some(held) = self.quality_evidence_return.as_mut() {
5105                held.lines_indexed(rows);
5106            }
5107            return;
5108        }
5109        if let Some((goto, row)) = self.goto_when_indexed.take()
5110            && goto == generation
5111        {
5112            self.take_down_the_counting_status();
5113            let _ = self.events.send(AppEvent::GoToLine(row));
5114        }
5115        if self.end_when_indexed.take() == Some(generation) {
5116            self.take_down_the_counting_status();
5117            if let Some(next) = self.jump_key(AppEvent::DoScrollEnd) {
5118                let _ = self.events.send(next);
5119                return;
5120            }
5121        }
5122        // The count the indexing held back starts now, and rows past the first ones
5123        // read are read.
5124        if self.in_normal_table_view() && !self.loading.awaiting_dataset() {
5125            self.spawn_collect(None);
5126        }
5127    }
5128
5129    /// Whether the dataset's count waits to be asked for: it has more files than
5130    /// `[read] exact_count_files` and an estimate to show meanwhile.
5131    fn count_held_at_estimate(&self, state: &DataTableState) -> bool {
5132        let limit = self.app_config.read.exact_count_files;
5133        limit > 0
5134            && state.files_to_count().is_some_and(|files| files > limit)
5135            && self.exact_count_asked != Some(self.dataset_generation)
5136            && state.row_estimate(None).is_some()
5137    }
5138
5139    /// The dataset's row count from a sample of its footers, while it is not counted:
5140    /// the dataset's own, or the one its footer pass has said so far.
5141    pub(crate) fn row_estimate(&self) -> Option<crate::schema_union::RowEstimate> {
5142        self.data_table_state
5143            .as_ref()?
5144            .row_estimate(self.footer_progress.estimate())
5145    }
5146
5147    /// Whether the count running reads footers it can say it has read, and so can be
5148    /// stopped: `(read, of)`.
5149    pub(crate) fn footers_counted(&self) -> Option<(usize, usize)> {
5150        self.len_count_inflight?;
5151        self.count_progress
5152            .reading()
5153            .filter(|_| !self.count_progress.is_cancelled())
5154    }
5155
5156    /// `c` in the Info panel: count every row exactly, though the dataset has more
5157    /// files than the count reads unasked.
5158    pub(crate) fn count_exactly(&mut self) {
5159        let Some(state) = self.data_table_state.as_ref() else {
5160            return;
5161        };
5162        if state.is_num_rows_valid() {
5163            return;
5164        }
5165        let generation = state.len_generation();
5166        self.exact_count_asked = Some(self.dataset_generation);
5167        // The footer pass is still bringing the count; the request holds for when it
5168        // lands.
5169        if state.counts_itself_later() {
5170            return;
5171        }
5172        // A count stopped before is asked again.
5173        if self.len_count_failed == Some(generation) {
5174            self.len_count_failed = None;
5175        }
5176        // One stopped and not yet wound down: again once it has.
5177        if self.len_count_inflight == Some(generation) && self.count_progress.is_cancelled() {
5178            self.count_after_stop = Some(generation);
5179            return;
5180        }
5181        if self.len_count_inflight != Some(generation) {
5182            self.len_count_inflight = Some(generation);
5183            let job = LenCount::for_state(state);
5184            self.spawn_count(job);
5185        }
5186    }
5187
5188    /// Esc while a count reads footers: stop it. What it read is kept for the next.
5189    fn stop_count(&mut self) {
5190        self.count_progress.cancel();
5191    }
5192
5193    /// Put what a pass found in the slot, unless a later dataset's pass has answered
5194    /// first. Returns whether it went in, so a pass that lost does not also announce
5195    /// itself.
5196    ///
5197    /// Two passes can be in flight at once — opening a second large prefix does not
5198    /// stop the first one reading — and they finish in whatever order the network
5199    /// gives. Without this the slower, older one overwrites the newer entry, and the
5200    /// generation the event carries then disagrees with the generation in the slot,
5201    /// so both are discarded and the dataset on screen never gets its columns.
5202    fn record_footers(
5203        slot: &std::sync::Mutex<FootersReported>,
5204        generation: u64,
5205        found: Option<crate::widgets::datatable::FootersFound>,
5206    ) -> bool {
5207        let mut slot = slot.lock().unwrap_or_else(|e| e.into_inner());
5208        if slot.as_ref().is_some_and(|(held, _)| *held > generation) {
5209            return false;
5210        }
5211        *slot = Some((generation, found));
5212        true
5213    }
5214
5215    /// Install the dataset an open read, and apply the view it opens with, if any.
5216    /// Returns whether that view is reading the first rows, which the caller then leaves
5217    /// to it.
5218    ///
5219    /// Only the loader hands one over, and only for the open in flight: an abandoned or
5220    /// replaced open's answer never gets this far.
5221    fn install_dataset(&mut self, loaded: loading::Loaded) -> bool {
5222        let loading::Loaded {
5223            state,
5224            path,
5225            options,
5226            debug_label,
5227            paths,
5228            recent,
5229            from_home,
5230            footers,
5231        } = loaded;
5232        let options = &options;
5233        // A key pressed at the dataset being replaced belongs to it, not to this one.
5234        self.end_when_the_footers_land = None;
5235        // Its companion, for the same reason. This one keys itself to a
5236        // `len_generation`, which says nothing about which dataset it belonged to, so
5237        // without clearing it here an End pressed on the directory the user walked away
5238        // from is still live against the one they opened next.
5239        self.end_after_count = None;
5240        // One per dataset that reaches the screen, rather than one per open started:
5241        // an open that fails leaves the last dataset up, and the pass still reading its
5242        // footers has to be able to finish into it.
5243        self.dataset_generation = self.dataset_generation.wrapping_add(1);
5244        self.quality_cache.clear();
5245        self.quality_samples.clear();
5246        self.quality_released.clear();
5247        // The objects may have changed since they were copied; a run on the dataset
5248        // opened now fetches them again.
5249        self.quality_copies.clear();
5250        self.quality_copy_released = None;
5251        self.quality_copy_unusable = None;
5252        self.quality_evidence_return = None;
5253        self.quality_evidence_label = None;
5254        // The findings narrowed to the last dataset's columns would hide this one's.
5255        self.analysis_modal.data_quality_findings = quality_report::FindingsView::default();
5256        self.analysis_modal.data_quality_evidence_read = None;
5257        // A query still running was over the dataset being replaced; its rollback
5258        // is that dataset's view. So was a view waiting on its pivot.
5259        self.query_running = None;
5260        self.jobs.supersede(|job| matches!(job, Job::ViewPivot(_)));
5261        // A sample being drawn was the last dataset's, and so were its paths.
5262        self.put_down_sample_draw();
5263        self.sample_paths.clear();
5264        // Whatever chart state survived belongs to the dataset being replaced.
5265        self.reset_chart_state();
5266        self.debug.schema_load = debug_label;
5267        // Home is now in the stack, so q pops back to it; never unset, since a
5268        // reread from the table (H) is not a new place.
5269        if from_home {
5270            self.opened_from_home = true;
5271        }
5272        // A frame handed over has no path to go back to.
5273        // Without the spec read: it holds the file's map, and a decompressed copy's map
5274        // keeps its disk space until the map goes, so it goes with the dataset.
5275        self.opened = paths.map(|paths| {
5276            let options = OpenOptions {
5277                format_read: None,
5278                sqlite: None,
5279                // Counted afresh by the next read.
5280                tail: None,
5281                prepared: None,
5282                ..options.clone()
5283            };
5284            (paths, options)
5285        });
5286        // Recorded once the dataset is installed: a file that fails to load is not one
5287        // anybody wants to get back to.
5288        if let Some(path) = recent {
5289            // Off the opening path. It takes a lock several instances may be contending
5290            // for -- opening a dataset must not queue behind another instance's
5291            // bookkeeping. Only the next home listing waits on it, on its worker.
5292            let cache = self.cache.clone();
5293            self.cache_writes.spawn(move || {
5294                cache.push_recent(&path);
5295            });
5296        }
5297        self.forget_the_rows_read();
5298        self.file_facts = None;
5299        let shown = home::catalogs(&self.app_config);
5300        self.codebook = path.as_deref().and_then(|p| home::codebook_for(&shown, p));
5301        self.catalog_entry = path
5302            .as_deref()
5303            .and_then(|p| home::catalog_entry_for(&shown, p));
5304        // The footers it still has to read are counted on the open's counter, which is
5305        // the dataset's now; the last dataset's pass, if any is left, stops.
5306        self.footer_progress.cancel();
5307        self.footer_progress = footers;
5308        self.data_table_state = Some(state);
5309        // A followed file's watcher starts with its dataset and stops with it.
5310        if options.follow
5311            && let Some(state) = self.data_table_state.as_mut()
5312        {
5313            match options.tail.as_deref() {
5314                Some(tail) => {
5315                    let follow = crate::follow::Follow::start(
5316                        tail.clone(),
5317                        self.app_config.read.follow_interval.duration(),
5318                        self.events.clone(),
5319                        options.spool.clone(),
5320                    );
5321                    state.start_following(if options.pipe {
5322                        follow.as_pipe()
5323                    } else {
5324                        follow
5325                    });
5326                    // Counted already, as the scan reads them: no count of its own.
5327                    state.follow_to(tail.rows(), false);
5328                }
5329                // A recording of something that cannot be read as it grows.
5330                None => self.flash_note(
5331                    "Only text and Arrow streams are followed: this shows what had arrived, and recording goes on"
5332                        .to_string(),
5333                ),
5334            }
5335        }
5336        // A count still waiting for the last dataset's rows to paint is not owed now.
5337        self.retire_a_count_the_rows_answered();
5338        self.path = path.clone();
5339        // Named for this file, so after its path is set.
5340        self.open_info_documentation();
5341        if let Some(ref p) = path {
5342            let read_as = self
5343                .data_table_state
5344                .as_ref()
5345                .and_then(DataTableState::read_as);
5346            self.original_file_format = Self::export_format_for(p, read_as.or(options.format));
5347            // CSV's delimiter: a comma unless the user named a separator. A `.tsv`
5348            // exports as TSV, whose preset is the tab; a tab in a `.csv` would reopen
5349            // as one column.
5350            self.original_file_delimiter = Some(options.separator_or(b','));
5351        } else {
5352            self.original_file_format = None;
5353            self.original_file_delimiter = None;
5354        }
5355        // A panel still up says what it says about the dataset on screen.
5356        if self.info_modal.active {
5357            self.read_file_facts();
5358            self.count_unfit();
5359        }
5360        // The dataset is on screen now; whatever it still has to learn about itself is
5361        // read behind it.
5362        self.start_pending_footers();
5363        self.start_indexing();
5364        // `#` for text and logs, unless the flag or the config said.
5365        if options.row_numbers_auto
5366            && let Some(state) = self.data_table_state.as_mut()
5367            && state.numbered_by_default()
5368        {
5369            state.set_row_numbers(true);
5370        }
5371        self.sort_filter_modal = SortFilterModal::new();
5372        self.pivot_melt_modal = PivotMeltModal::new();
5373        self.status_message = Some(Self::LOADING_BUFFER.to_string());
5374
5375        // The dataset is installed and its schema known, so this is where a view
5376        // meets it. `--view` names one and applies to this first open alone;
5377        // `[views] auto_apply` dresses every open that has a matching view.
5378        // A fresh dataset starts with no view applied: the previous file's view
5379        // must not wear the check mark here, nor count as applied when edited.
5380        self.active_view_id = None;
5381        let (view, reason) = match self.startup_view.take() {
5382            Some(name) => match self.view_manager.get_view_by_name(&name).cloned() {
5383                Some(view) => (Some(view), None),
5384                None => {
5385                    self.error_modal.show(format!("No view named \"{name}\""));
5386                    (None, None)
5387                }
5388            },
5389            None if self.app_config.views.auto_apply => self
5390                .view_dataset()
5391                .zip(self.data_table_state.as_ref())
5392                .and_then(|(dataset, state)| {
5393                    self.view_manager
5394                        .get_most_relevant(dataset, state.source_schema())
5395                })
5396                .map_or((None, None), |(view, reason)| (Some(view), Some(reason))),
5397            None => (None, None),
5398        };
5399        let Some(view) = view else {
5400            return false;
5401        };
5402        let applied = match reason {
5403            // Applied unasked, it says which view and why.
5404            Some(why) => self.apply_matched_view(&view, why),
5405            None => self.apply_view(&view),
5406        };
5407        match applied {
5408            // The view reads its own first rows, so the dataset's are never read.
5409            Ok(()) => true,
5410            Err(e) => {
5411                self.error_modal
5412                    .show(format!("Error applying view \"{}\": {e}", view.name));
5413                false
5414            }
5415        }
5416    }
5417
5418    /// Say that the view `name` was applied because its criteria fit as `why` says.
5419    fn flash_view_applied(&mut self, name: &str, why: view::MatchReason) {
5420        self.flash_note(format!("View \"{name}\" applied: {}", why.as_str()));
5421    }
5422
5423    /// Ensures file path has an extension when user did not provide one; only adds
5424    /// compression suffix (e.g. .gz) when compression is selected. If the user
5425    /// provided a path with an extension (e.g. foo.feather), that extension is kept.
5426    /// Spawn an async buffer collect if needed. Returns true if a background task was spawned.
5427    /// Increments task_generation to invalidate any in-flight collect from a prior call.
5428    ///
5429    /// When the LazyFrame's row count is unknown (e.g. fresh load, or just after a
5430    /// filter/sort/pivot/melt that invalidates the cache), the exact `len()` is computed
5431    /// in the background and applied later via `BackgroundLenReady` — it never gates the
5432    /// buffer paint. `prepare_async_collect` plans a top-of-data window when the count is
5433    /// still unknown, so the first screen renders immediately. For large/partitioned/remote
5434    /// datasets the count can take a long time; it runs silently and concurrently.
5435    pub fn spawn_async_collect(&mut self, status: &str) -> bool {
5436        self.spawn_collect(Some(status))
5437    }
5438
5439    /// As [`Self::spawn_async_collect`]; with no `status`, a load-ahead that nothing
5440    /// waits on: its job holds no keys. See [`InflightCollect`].
5441    fn spawn_collect(&mut self, status: Option<&str>) -> bool {
5442        let held = self
5443            .data_table_state
5444            .as_ref()
5445            .is_some_and(|state| self.count_held_at_estimate(state));
5446        let Some(state) = self.data_table_state.as_mut() else {
5447            return false;
5448        };
5449
5450        // The exact row count, when it isn't known and none is already coming for this
5451        // data version. Independent of `task_generation` (a scroll must not restart it)
5452        // and does not set `busy`. A count that reads only footers runs now: it reads no
5453        // data. On an object store a data count rides in the collect spawned below,
5454        // which answers it outright when the read comes back short and otherwise gets
5455        // the row groups to itself first. On a local frame it waits until the page is
5456        // painted (`count_after_paint`), and is not needed at all when that page came
5457        // back short.
5458        let mut count = None;
5459        let generation = state.len_generation();
5460        if !state.is_num_rows_valid()
5461            && self.len_count_inflight != Some(generation)
5462            // Marked as running only once it is going to run. A dataset still reading
5463            // its own footers declines this count, because that pass is bringing it —
5464            // and the marker is cleared by a count coming back, so setting it for one
5465            // that was never started leaves it set for the rest of the session: a
5466            // spinner where the row count goes, a redraw on its account every frame,
5467            // and `End` waiting on nothing.
5468            && !state.counts_itself_later()
5469            // A count that failed is not tried again on every scroll. End asks again.
5470            && self.len_count_failed != Some(generation)
5471            // A dataset of too many files to count unasked shows its estimate.
5472            && !held
5473        {
5474            self.len_count_inflight = Some(generation);
5475            count = Some(LenCount::for_state(state));
5476        }
5477        let footers = count.take_if(|job| job.reads_footers());
5478        if count.take_if(|_| !state.is_remote_source()).is_some() {
5479            self.count_after_paint = Some(generation);
5480        }
5481        if let Some(job) = footers {
5482            self.spawn_count(job);
5483        }
5484
5485        // Read before the frame is borrowed: the predicate is over the whole App.
5486        let a_bump_would_strand = self.work_a_bump_would_strand();
5487        let inflight = self.rows_in_flight();
5488
5489        // Plan and spawn the buffer collect. With the count unknown this is a top-of-data
5490        // window (`slice(0, N)`) that touches only the first file(s) of a partitioned set.
5491        let Some(state) = self.data_table_state.as_mut() else {
5492            return false;
5493        };
5494        let covered = inflight.is_some_and(|inflight| inflight.covers(state));
5495        // The rows asked for are already on the way in a load-ahead: wait on that one
5496        // rather than fetch them twice. The keys are its now.
5497        if covered
5498            && let Some(status) = status
5499            && !self.jobs.waited_on(Self::reading_rows)
5500        {
5501            self.jobs.wait_on(Self::reading_rows, status);
5502            self.busy = false;
5503            self.status_message = Some(status.to_string());
5504        }
5505        let request = (!covered).then(|| state.prepare_async_collect(None));
5506        let Some(Some(request)) = request else {
5507            // Nothing to ride in: the view is covered, or the buffer on hand serves it.
5508            if let Some(job) = count {
5509                self.spawn_count(job);
5510            }
5511            return covered;
5512        };
5513        // Everything past here advances the generation, so everything that holds it has
5514        // to be done first. The collect the user asked for is queued rather than
5515        // refused: the throbber that was already turning goes on turning, and it is
5516        // tried again after every event until the work in front of it finishes.
5517        //
5518        // This is the door #238 was about. `BackgroundLenReady` answers a count by
5519        // jumping to the end, which reaches here with no key pressed and minutes after
5520        // the one that was — long enough for a dataset to have been opened meanwhile.
5521        // The bump cancelled that open's phase in flight, whose answer was then thrown
5522        // away with the open still waiting on it, and the file never opened, silently,
5523        // for the rest of the session.
5524        if a_bump_would_strand {
5525            // The count that was going to ride in this collect is put down rather than
5526            // run on its own. On an object store it answers itself out of the short read
5527            // the collect comes back with; spawned standalone it is a full remote
5528            // `len()`, which is the expensive thing the riding exists to avoid. Putting
5529            // the marker down with it is what lets the retry ask again.
5530            if count.is_some() {
5531                self.len_count_inflight = None;
5532            }
5533            // A load-ahead is not owed: nobody asked for it.
5534            let Some(status) = status else {
5535                return false;
5536            };
5537            // One page is owed at a time, the newest; the user waits on it.
5538            self.jobs.take_owed(Self::owed_rows);
5539            let owed = Job::OwedRows {
5540                dataset: self.dataset_generation,
5541                status: status.to_string(),
5542            };
5543            self.jobs.owe(owed, Some(status));
5544            self.busy = false;
5545            self.status_message = Some(status.to_string());
5546            return true;
5547        }
5548        self.jobs.advance();
5549        self.reads.pages += 1;
5550        let inflight = InflightCollect {
5551            began: std::time::Instant::now(),
5552            files: state.files_a_page_reads(
5553                request.buffer_start,
5554                request.buffer_end.saturating_sub(request.buffer_start),
5555            ),
5556            dataset: state.len_generation(),
5557            columns: InflightCollect::columns_of(state),
5558            start: request.buffer_start,
5559            end: request.buffer_end,
5560        };
5561        // Owed from here, so a worker that dies before it reaches the count still
5562        // answers it.
5563        let count = count.map(|job| OwedCount::new(job, self.events.clone()));
5564        self.spawn_job(Job::Rows(inflight), status, move |_| {
5565            let plan = request.plan;
5566            // The count is answered once the page has gone out: it may need a pass of
5567            // its own, which must not hold the page back.
5568            Ok(
5569                match crate::statistics::collect_lazy(request.lf, request.polars_streaming) {
5570                    Ok(df) => {
5571                        let returned = df.height();
5572                        let requested = request.buffer_end - request.buffer_start;
5573                        let start = request.buffer_start;
5574                        // Stitched and cut here rather than where it lands: a cut may
5575                        // copy up to the byte budget, which the UI thread would stall on
5576                        // (#483).
5577                        Answer::Rows(plan.fit(df)).then(move || {
5578                            if let Some(count) = count {
5579                                count.answer(|job| job.after_collect(start, returned, requested));
5580                            }
5581                        })
5582                    }
5583                    // A pass over a frame that just failed to collect would fail too: the
5584                    // count goes unanswered and so reports itself failed, leaving the
5585                    // retry to a later interaction (see `len_count_failed`).
5586                    Err(e) => Answer::RowsFailed {
5587                        message: crate::error_display::user_message_from_polars(&e),
5588                        conversion: crate::error_display::conversion_failure(&e).map(Box::new),
5589                    }
5590                    .then(move || drop(count)),
5591                },
5592            )
5593        });
5594        true
5595    }
5596
5597    /// Count the rows off the UI thread; the answer comes back as `BackgroundLenReady`
5598    /// or `BackgroundLenFailed`.
5599    fn spawn_count(&mut self, job: LenCount) {
5600        #[cfg(test)]
5601        self.counts_spawned.set(self.counts_spawned.get() + 1);
5602        self.count_progress = job.progress.clone();
5603        let count = OwedCount::new(job, self.events.clone());
5604        self.runtime
5605            .spawn_blocking(move || count.answer(LenCount::run));
5606    }
5607
5608    /// Whether the rows of the frame on screen that someone is waiting for are still
5609    /// being read: the page an open, a query or a scroll asked for. A load-ahead is
5610    /// nobody's wait, so a count does not queue behind one.
5611    fn waited_on_rows_pending(&self, generation: u64) -> bool {
5612        self.loading.awaiting_dataset()
5613            || self.jobs.owed(Self::owed_rows).is_some()
5614            || (self.rows_waited_on()
5615                && self
5616                    .rows_in_flight()
5617                    .is_some_and(|inflight| inflight.dataset == generation))
5618    }
5619
5620    /// Whether a frame painted now would start, or retire, the count waiting on one.
5621    /// The run loop paints after every update; a test harness, which paints nothing,
5622    /// asks this to know when to say a frame was painted.
5623    pub fn count_waits_for_a_frame(&self) -> bool {
5624        self.count_after_paint
5625            .is_some_and(|generation| !self.waited_on_rows_pending(generation))
5626    }
5627
5628    /// A frame has been painted. Start the count that was waiting for its rows to be on
5629    /// screen, unless they are still being read; retire it if the frame it was for has
5630    /// gone or its rows already said how many there are.
5631    pub fn frame_painted(&mut self) {
5632        self.pointer.painted();
5633        let Some(generation) = self.count_after_paint else {
5634            return;
5635        };
5636        if self.waited_on_rows_pending(generation) {
5637            return;
5638        }
5639        self.count_after_paint = None;
5640        let wanted = self
5641            .data_table_state
5642            .as_ref()
5643            .filter(|state| state.len_generation() == generation && !state.is_num_rows_valid());
5644        match wanted {
5645            Some(state) => {
5646                self.len_count_inflight = Some(generation);
5647                self.spawn_count(LenCount::for_state(state));
5648            }
5649            None => {
5650                if self.len_count_inflight == Some(generation) {
5651                    self.len_count_inflight = None;
5652                }
5653            }
5654        }
5655    }
5656
5657    /// The page just installed may have said how many rows there are, or belong to a
5658    /// frame other than the one a count is waiting on: either way that count is not
5659    /// owed any more.
5660    fn retire_a_count_the_rows_answered(&mut self) {
5661        let Some(generation) = self.count_after_paint else {
5662            return;
5663        };
5664        let answered = self
5665            .data_table_state
5666            .as_ref()
5667            .is_none_or(|state| state.len_generation() != generation || state.is_num_rows_valid());
5668        if answered {
5669            self.count_after_paint = None;
5670            if self.len_count_inflight == Some(generation) {
5671                self.len_count_inflight = None;
5672            }
5673        }
5674    }
5675
5676    /// Start `job` on a worker. With a `status` the app is busy with it: the control
5677    /// bar says so and keys wait. The worker returns its answer, or `Err` with a
5678    /// message for the user; that, or a panic, is the job's one outcome, which
5679    /// [`AppEvent::JobEnded`] hands to [`App::job_ended`].
5680    ///
5681    /// Does not advance the generation. A caller replacing work in flight advances it
5682    /// first.
5683    fn spawn_job<F, R>(&mut self, job: Job, status: Option<&str>, work: F) -> Ticket
5684    where
5685        F: FnOnce(&jobs::Worker) -> std::result::Result<R, String> + Send + 'static,
5686        R: Into<jobs::Answered>,
5687    {
5688        let started = self.start_job(job, status);
5689        let ticket = started.ticket();
5690        started.run(&self.runtime, work);
5691        ticket
5692    }
5693
5694    /// As [`Self::spawn_job`], for a caller that needs the ticket before the work is
5695    /// built: it runs the job with [`jobs::Started::run`].
5696    fn start_job(&mut self, job: Job, status: Option<&str>) -> jobs::Started {
5697        if let Some(status) = status {
5698            // The errand that led here, if one did, is this job's now: its record holds
5699            // the keys until it ends.
5700            self.busy = false;
5701            self.status_message = Some(status.to_string());
5702        }
5703        self.jobs.start(job, status)
5704    }
5705
5706    fn owed_rows(job: &Job) -> bool {
5707        matches!(job, Job::OwedRows { .. })
5708    }
5709
5710    fn reading_rows(job: &Job) -> bool {
5711        matches!(job, Job::Rows(_))
5712    }
5713
5714    /// The read of the table's rows in flight, if one is and is still wanted: what it
5715    /// will fill, and whether anyone waits on it.
5716    fn rows_in_flight(&self) -> Option<InflightCollect> {
5717        match self.jobs.current(Self::reading_rows) {
5718            Some((_, Job::Rows(inflight))) => Some(*inflight),
5719            _ => None,
5720        }
5721    }
5722
5723    /// Whether the user waits on the read of the table's rows in flight.
5724    fn rows_waited_on(&self) -> bool {
5725        self.jobs.waited_on(Self::reading_rows)
5726    }
5727
5728    /// The rows being read, or owed, are not for the table on screen any more: a view
5729    /// has been put in its place. Their answer is dropped when it comes.
5730    fn forget_the_rows_read(&mut self) {
5731        self.jobs
5732            .supersede(|job| Self::reading_rows(job) || Self::owed_rows(job));
5733    }
5734
5735    /// Hold the generation: a continuation waiting to run, or an errand waiting on the
5736    /// user. See [`jobs::Hold`].
5737    pub(crate) fn hold_the_generation(&self) -> jobs::Hold {
5738        self.jobs.hold()
5739    }
5740
5741    /// A job in flight with no worker, started as `spawn_job` starts one, for tests
5742    /// that decide how it ends.
5743    #[cfg(test)]
5744    pub(crate) fn job_for_tests(&mut self, job: Job, status: Option<&str>) -> jobs::Started {
5745        self.start_job(job, status)
5746    }
5747
5748    /// Whether the bar has no open and no export to report.
5749    #[cfg(test)]
5750    pub(crate) fn nothing_loading(&self) -> bool {
5751        self.loading.current().is_none() && self.export_progress.is_none()
5752    }
5753
5754    /// An open on the loading screen, saying `phase` about `path` of `size` bytes, with
5755    /// nothing running: for tests of what the screen says.
5756    #[cfg(test)]
5757    pub(crate) fn loading_for_tests(
5758        &mut self,
5759        path: Option<PathBuf>,
5760        size: u64,
5761        phase: &str,
5762        percent: u16,
5763    ) {
5764        self.announce_open(false, phase.to_string(), percent);
5765        if let Some(path) = path {
5766            self.loading.name(path);
5767        }
5768        self.loading.size_for_tests(size);
5769    }
5770
5771    /// An open of `path` begun and scanning, with no worker: for tests that decide how
5772    /// its phases answer. Its jobs are [`Job::Load`] with the id returned.
5773    #[cfg(test)]
5774    pub(crate) fn open_for_tests(&mut self, path: &str) -> loading::LoadId {
5775        self.make_way_for_an_open();
5776        let _ = self.loading.open(loading::OpenRequest {
5777            paths: vec![PathBuf::from(path)],
5778            options: OpenOptions::default(),
5779            size: 0,
5780            recent: None,
5781            shown: None,
5782            warn_in_memory_above: None,
5783        });
5784        self.loading.id().expect("an open was begun")
5785    }
5786
5787    /// Install `state` as the dataset on screen, the way an open of a frame does: through
5788    /// the loader, so what the load hands over (its footer counter) is handed over for
5789    /// real. Its first rows are not read; the open is done once it is installed.
5790    #[cfg(test)]
5791    pub(crate) fn install_for_tests(
5792        &mut self,
5793        state: DataTableState,
5794        path: Option<PathBuf>,
5795        options: &OpenOptions,
5796        debug_label: Option<String>,
5797    ) -> bool {
5798        self.make_way_for_an_open();
5799        let _ = self
5800            .loading
5801            .open_frame(LazyFrame::default(), options.clone());
5802        let load = self.loading.id().expect("an open was begun");
5803        let loading::Step::Install(loaded) = self.loading.answered(
5804            load,
5805            loading::LoadAnswer::SchemaRead {
5806                state: Box::new(state),
5807                path,
5808                options: options.clone(),
5809                debug_label,
5810            },
5811            #[cfg(any(feature = "http", feature = "cloud"))]
5812            &self.jobs,
5813        ) else {
5814            unreachable!("a schema read installs");
5815        };
5816        let view = self.install_dataset(*loaded);
5817        self.first_rows_settled();
5818        view
5819    }
5820
5821    /// A page owed to the dataset on screen, as one asked for while the generation was
5822    /// held is.
5823    #[cfg(test)]
5824    pub(crate) fn owe_rows_for_tests(&mut self, status: &str) {
5825        let owed = Job::OwedRows {
5826            dataset: self.dataset_generation,
5827            status: status.to_string(),
5828        };
5829        self.jobs.owe(owed, Some(status));
5830    }
5831
5832    /// Whether a page is owed.
5833    #[cfg(test)]
5834    pub(crate) fn rows_owed(&self) -> bool {
5835        self.jobs.owed(Self::owed_rows).is_some()
5836    }
5837
5838    /// A current `job` answers `answer` at once, and the app handles it: for tests of
5839    /// what an answer does.
5840    #[cfg(test)]
5841    pub(crate) fn answer_for_tests(&mut self, job: Job, answer: Answer) -> Option<AppEvent> {
5842        let started = self.jobs.start(job, None);
5843        let ticket = started.ticket();
5844        started.end(Outcome::answered(answer));
5845        self.job_ended(ticket)
5846    }
5847
5848    /// Whether anything is waiting on the current generation, so that advancing it
5849    /// would throw away an answer nothing will ask for again. See
5850    /// [`Jobs::would_strand`].
5851    fn work_a_bump_would_strand(&self) -> bool {
5852        self.jobs.would_strand()
5853    }
5854
5855    /// Run a scroll on `data_table_state` and resolve the busy/spawn cycle.
5856    /// `scroll` returns true when its movement leaves the buffered window (caller must collect).
5857    /// We clear `busy` ourselves when no collect is needed or the spawn no-ops, otherwise
5858    /// the busy flag set by the key handler would gate further input forever.
5859    /// Home, End and G. A jump may need a fill, so it is deferred behind a frame that
5860    /// shows the throbber — setting `start_row` alone used to leave the old buffer on
5861    /// screen, drawn from its first row — unless the view is already there, in which
5862    /// case only the selection settles and no frame or key is spent.
5863    fn jump_key(&mut self, jump: AppEvent) -> Option<AppEvent> {
5864        // The end of a remote dataset is not known until its rows are counted, and a
5865        // jump to a guess reads every file up to it. Wait for the count instead; keys
5866        // keep working meanwhile.
5867        // A dataset still reading its own footers is already getting a count, and its
5868        // end is known as soon as that lands. Starting one here would read every footer
5869        // a second time — and the join takes a fresh `len_generation` on its way past,
5870        // so the count that came back would be answering a question nobody could match
5871        // it to and the jump would never happen. Wait for the pass instead.
5872        // `scan_is_the_root`, not `counts_itself_later`: the question here is whether a
5873        // join is going to land underneath this frame and take a fresh `len_generation`
5874        // with it, which is what would leave a count answering a question nothing could
5875        // match it to. A filter and a sort are rebuilt over the joined scan, so they are
5876        // on this side of it even though they are not pristine.
5877        // Lines still being indexed: the end is where the indexing ends.
5878        if matches!(jump, AppEvent::DoScrollEnd)
5879            && let Some(state) = self.data_table_state.as_ref()
5880            && state.indexing().is_some()
5881        {
5882            self.end_when_indexed = Some(self.dataset_generation);
5883            self.status_message = Some(Self::COUNTING_FOR_END.to_string());
5884            return None;
5885        }
5886        if matches!(jump, AppEvent::DoScrollEnd)
5887            && let Some(state) = self.data_table_state.as_ref()
5888            && state.footers_pending().is_some()
5889            && state.scan_is_the_root()
5890            && !state.is_num_rows_valid()
5891        {
5892            self.end_when_the_footers_land = Some(self.dataset_generation);
5893            self.status_message = Some(Self::COUNTING_FOR_END.to_string());
5894            return None;
5895        }
5896        // Any other frame whose end is not known yet waits for its count too, rather than
5897        // jumping to the end of the rows read so far. A count waiting on a paint starts
5898        // now; one already running or riding in a collect is waited on.
5899        if matches!(jump, AppEvent::DoScrollEnd)
5900            && let Some(state) = self.data_table_state.as_ref()
5901            && !state.is_num_rows_valid()
5902        {
5903            let generation = state.len_generation();
5904            self.end_after_count = Some(generation);
5905            self.status_message = Some(Self::COUNTING_FOR_END.to_string());
5906            let held = self.count_after_paint == Some(generation);
5907            if held {
5908                self.count_after_paint = None;
5909            }
5910            if held || self.len_count_inflight != Some(generation) {
5911                self.len_count_inflight = Some(generation);
5912                self.spawn_count(LenCount::for_state(state));
5913            }
5914            return None;
5915        }
5916        let state = self.data_table_state.as_mut()?;
5917        let (already_there, settle): (bool, fn(&mut DataTableState) -> bool) = match jump {
5918            AppEvent::DoScrollHome => (state.start_row() == 0, DataTableState::scroll_to_start),
5919            _ => (state.at_end(), DataTableState::scroll_to_end),
5920        };
5921        if already_there {
5922            settle(state);
5923            return None;
5924        }
5925        self.busy = true;
5926        Some(jump)
5927    }
5928
5929    fn handle_scroll<F>(&mut self, scroll: F) -> Option<AppEvent>
5930    where
5931        F: FnOnce(&mut crate::widgets::datatable::DataTableState) -> bool,
5932    {
5933        let needs = self.data_table_state.as_mut().is_some_and(scroll);
5934        if !needs || !self.spawn_async_collect(Self::LOADING_BUFFER) {
5935            self.busy = false;
5936            self.status_message = None;
5937        }
5938        None
5939    }
5940
5941    /// Hand the export modal's path input a key, and when the value changed, follow
5942    /// the typed extension with the format radio — the alternative was Parquet bytes
5943    /// in a file named `out.csv`, with nothing on screen saying so. Cursor-only keys
5944    /// change nothing and re-pick nothing, so a format chosen after typing stands.
5945    fn export_path_key(&mut self, event: &KeyEvent) {
5946        let before = self.export_modal.path_input.value().to_string();
5947        self.export_modal
5948            .path_input
5949            .handle_key(event, Some(&self.cache));
5950        if self.export_modal.path_input.value() != before {
5951            self.export_modal.sync_format_to_path();
5952            // Typing is the correction the message asked for.
5953            self.export_modal.path_error = None;
5954        }
5955    }
5956
5957    fn ensure_file_extension(
5958        path: &Path,
5959        format: ExportFormat,
5960        compression: Option<CompressionFormat>,
5961    ) -> PathBuf {
5962        let current_ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
5963        let mut new_path = path.to_path_buf();
5964
5965        if current_ext.is_empty() {
5966            // No extension: use default for format (and add compression if selected)
5967            let desired_ext = if let Some(comp) = compression {
5968                format!("{}.{}", format.extension(), comp.extension())
5969            } else {
5970                format.extension().to_string()
5971            };
5972            new_path.set_extension(&desired_ext);
5973        } else {
5974            // User provided an extension: keep it. Only add compression suffix when compression is selected.
5975            let is_compression_only = matches!(
5976                current_ext.to_lowercase().as_str(),
5977                "gz" | "zst" | "bz2" | "xz"
5978            ) && ExportFormat::from_extension(current_ext).is_none();
5979
5980            if is_compression_only {
5981                // Path has only compression ext (e.g. file.gz); stem may have format (file.csv.gz)
5982                let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
5983                let stem_has_format = stem
5984                    .split('.')
5985                    .next_back()
5986                    .and_then(ExportFormat::from_extension)
5987                    .is_some();
5988                if stem_has_format {
5989                    if let Some(comp) = compression
5990                        && let Some(format_ext) = stem
5991                            .split('.')
5992                            .next_back()
5993                            .and_then(ExportFormat::from_extension)
5994                            .map(|f| f.extension())
5995                    {
5996                        new_path =
5997                            PathBuf::from(stem.rsplit_once('.').map(|x| x.0).unwrap_or(stem));
5998                        new_path.set_extension(format!("{}.{}", format_ext, comp.extension()));
5999                    }
6000                } else if let Some(comp) = compression {
6001                    new_path.set_extension(format!("{}.{}", format.extension(), comp.extension()));
6002                } else {
6003                    new_path.set_extension(format.extension());
6004                }
6005            } else if let Some(comp) = compression
6006                && format.supports_compression()
6007            {
6008                new_path.set_extension(format!("{}.{}", current_ext, comp.extension()));
6009            }
6010            // else: path stays as-is (e.g. foo.feather stays foo.feather)
6011            // else: path with format extension stays as-is
6012        }
6013
6014        new_path
6015    }
6016
6017    pub fn new(events: Sender<AppEvent>, runtime: tokio::runtime::Handle) -> App {
6018        // Create default theme for backward compatibility
6019        let theme = Theme::from_config(&AppConfig::default().theme).unwrap_or_else(|_| {
6020            // Create a minimal fallback theme
6021            Theme {
6022                colors: std::collections::HashMap::new(),
6023            }
6024        });
6025
6026        Self::new_with_config(events, runtime, theme, AppConfig::default())
6027    }
6028
6029    pub fn new_with_theme(
6030        events: Sender<AppEvent>,
6031        runtime: tokio::runtime::Handle,
6032        theme: Theme,
6033    ) -> App {
6034        Self::new_with_config(events, runtime, theme, AppConfig::default())
6035    }
6036
6037    /// An app with its saved views read here, before it is returned.
6038    pub fn new_with_config(
6039        events: Sender<AppEvent>,
6040        runtime: tokio::runtime::Handle,
6041        theme: Theme,
6042        app_config: AppConfig,
6043    ) -> App {
6044        let views = ViewManager::load_or_empty().into();
6045        Self::new_with_views(events, runtime, theme, app_config, views)
6046    }
6047
6048    /// An app whose saved views may still be on their way ([`Views`]).
6049    pub fn new_with_views(
6050        events: Sender<AppEvent>,
6051        runtime: tokio::runtime::Handle,
6052        theme: Theme,
6053        app_config: AppConfig,
6054        view_manager: Views,
6055    ) -> App {
6056        let cache = CacheManager::new(APP_NAME).unwrap_or_else(|_| CacheManager {
6057            cache_dir: std::env::temp_dir().join(APP_NAME),
6058        });
6059        let jobs = Jobs::new(events.clone());
6060        let formats = Arc::new(crate::formats::Registry::load(
6061            &crate::formats::search_path_for(&app_config),
6062        ));
6063        for error in &formats.errors {
6064            log::warn!("format spec skipped: {error}");
6065        }
6066
6067        let theme_problem = app_config.theme.fallbacks.first().cloned();
6068        let chart_export_modal = ChartExportModal {
6069            recipe: app_config.chart.export_recipe,
6070            ..ChartExportModal::new()
6071        };
6072        let mut app = App {
6073            path: None,
6074            data_table_state: None,
6075            footer_progress: Arc::new(crate::schema_union::FooterProgress::default()),
6076            footers_this_frame: None,
6077            listed_this_frame: None,
6078            home: home::HomeState {
6079                hide_unreadable: !app_config.home.show_unreadable,
6080                formats: formats.clone(),
6081                ..Default::default()
6082            },
6083            home_probes_inflight: Vec::new(),
6084            home_listing_cancels: HashMap::new(),
6085            home_narrowing: None,
6086            #[cfg(feature = "cloud")]
6087            cloud_discovery_started: false,
6088            home_search_inflight: false,
6089            home_search_generation: 0,
6090            home_generation: 0,
6091            home_schema_inflight: Vec::new(),
6092            last_load_error: None,
6093            pending_clear_recents: false,
6094            pending_link: None,
6095            local_desktop: link_open::local_desktop(link_open::Platform::current(), |name| {
6096                std::env::var(name).ok()
6097            }),
6098            pending_forget_place: None,
6099            home_schema_cache: HashMap::new(),
6100            home_previews: crate::home_preview::Previews::default(),
6101            reads: crate::home_preview::ReadCounts::default(),
6102            shape_remembered: None,
6103            original_file_format: None,
6104            original_file_delimiter: None,
6105            stdin_reader: None,
6106            stdout_pass: None,
6107            follow_drawn: None,
6108            pending_leave: None,
6109            recording_on: None,
6110            recording_end_said: false,
6111            events,
6112            debug: DebugState::default(),
6113            info_modal: InfoModal::new(),
6114            file_facts: None,
6115            codebook: None,
6116            catalog_entry: None,
6117            documentation: Default::default(),
6118            info_documentation: Default::default(),
6119            remembered_moved: false,
6120            head_web_rows: !cache::running_as_a_cargo_test(),
6121            query_input: TextInput::new()
6122                .with_history_limit(app_config.query.history_limit)
6123                .with_theme(&theme)
6124                .with_history("query".to_string()),
6125            sql_input: TextInput::statement()
6126                .with_history_limit(app_config.query.history_limit)
6127                .with_theme(&theme)
6128                .with_history("sql".to_string()),
6129            find: find::Find::new(
6130                TextInput::new()
6131                    .with_history_limit(app_config.query.history_limit)
6132                    .with_theme(&theme)
6133                    .with_history("find".to_string()),
6134            ),
6135            column_hints: false,
6136            input_mode: InputMode::Normal,
6137            input_type: None,
6138            query_mode: QueryMode::default().resolve(),
6139            query_mode_chosen: None,
6140            query_text_restored: false,
6141            sql_columns: Vec::new(),
6142            sql_completion: None,
6143            query_running: None,
6144            query_run_error: None,
6145            inline_failures: 0,
6146            sort_filter_modal: SortFilterModal::new(),
6147            pivot_melt_modal: PivotMeltModal::new(),
6148            view_modal: ViewModal::new(),
6149            opened_from_home: false,
6150            startup_view: None,
6151            analysis_modal: AnalysisModal::with_sample_rows(app_config.analysis.sample_rows),
6152            sample_form: None,
6153            memory_probe: std::sync::Arc::new(table_sample::available_memory),
6154            sample_paths: Vec::new(),
6155            quality_cache: Vec::new(),
6156            quality_samples: Vec::new(),
6157            quality_released: Vec::new(),
6158            quality_memory_budget: QUALITY_MEMORY_BUDGET,
6159            quality_copies: Vec::new(),
6160            quality_copy_released: None,
6161            quality_copy_unusable: None,
6162            quality_copy_free: std::sync::Mutex::new(None),
6163            quality_evidence_return: None,
6164            quality_evidence_label: None,
6165            chart_modal: ChartModal::new(),
6166            chart_export_modal,
6167            export_modal: ExportModal::new(),
6168            copy_modal: copy_modal::CopyModal::new(),
6169            inspector_modal: inspector_modal::InspectorModal::new(),
6170            external_open: None,
6171            open_dir: None,
6172            go_to_column: crate::widgets::ui::PickerState::default(),
6173            value_counts: value_counts_modal::ValueCountsModal::default(),
6174            hex: None,
6175            hex_serial: 0,
6176            export_counts: None,
6177            format_picker: crate::widgets::ui::PickerState::default(),
6178            retype: None,
6179            combine: None,
6180            retype_from_info: false,
6181            table_picker: crate::widgets::ui::PickerState::default(),
6182            table_choices: None,
6183            clipboard: None,
6184            pending_copy: None,
6185            chart_cache: ChartCache::default(),
6186            chart_inflight: None,
6187            chart_asked: None,
6188            pending_chart_result: Arc::new(Mutex::new(None)),
6189            chart_export_waiting: None,
6190            error_modal: ErrorModal::new(),
6191            flash: None,
6192            confirmation_modal: ConfirmationModal::new(),
6193            pending_export: None,
6194            pending_delete_view: None,
6195            pending_hide_examples: false,
6196            pending_chart_export: None,
6197            pending_quality_export: None,
6198            help: help::Help::default(),
6199            pointer: pointer::Pointing::default(),
6200            context_menu: None,
6201            cache,
6202            cache_writes: CacheWrites::default(),
6203            view_manager,
6204            active_view_id: None,
6205            export_progress: None,
6206            theme,
6207            pending_read_all: false,
6208            history_limit: app_config.query.history_limit,
6209            table_cell_padding: app_config.display.cell_padding.cells(),
6210            column_colors: app_config.display.column_colors,
6211            dtype_row: app_config.display.type_row,
6212            number_format: app_config
6213                .display
6214                .number_format
6215                .resolve(app_config.display.right_align_numbers)
6216                // AppConfig::load validates this, but App can be built from an
6217                // unvalidated config (e.g. the Python API): fall back to no
6218                // formatting while still honouring the alignment setting.
6219                .unwrap_or_else(|_| NumberFormatSettings {
6220                    align_numeric_right: app_config.display.right_align_numbers,
6221                    ..Default::default()
6222                }),
6223            jobs,
6224            runtime,
6225            loading: loading::Loader::default(),
6226            opened: None,
6227            loaded_ahead_from: None,
6228            pending_footers_result: std::sync::Arc::new(std::sync::Mutex::new(None)),
6229            dataset_generation: 0,
6230            footers_held: None,
6231            followed_fields_held: None,
6232            reread_owed: None,
6233            #[cfg(test)]
6234            home_worker_dies: None,
6235            #[cfg(test)]
6236            file_facts_reader: None,
6237            end_when_the_footers_land: None,
6238            end_when_indexed: None,
6239            indexing_stop: Arc::default(),
6240            indexing_lines: None,
6241            indexing_paused: false,
6242            goto_when_indexed: None,
6243            count_progress: Arc::default(),
6244            exact_count_asked: None,
6245            count_after_stop: None,
6246            len_count_inflight: None,
6247            count_after_paint: None,
6248            #[cfg(test)]
6249            counts_spawned: std::cell::Cell::new(0),
6250            #[cfg(test)]
6251            first_rows_asked: 0,
6252            len_count_failed: None,
6253            end_after_count: None,
6254            busy: false,
6255            throbber_frame: 0,
6256            screen_generation: 0,
6257            input_dropped: false,
6258            status_message: None,
6259            analysis_computation: None,
6260            app_config,
6261            background_query: false,
6262            formats,
6263        };
6264        // A theme that could not be used: why is said on stderr after exit.
6265        if let Some(problem) = theme_problem {
6266            app.flash_note(problem);
6267        }
6268        app
6269    }
6270
6271    /// Use `registry` as the format specs on the search path, for hosts and tests that
6272    /// have their specs in hand.
6273    pub fn set_formats(&mut self, registry: crate::formats::Registry) {
6274        let registry = Arc::new(registry);
6275        self.home.formats = registry.clone();
6276        self.formats = registry;
6277    }
6278
6279    pub fn enable_debug(&mut self) {
6280        self.debug.enabled = true;
6281    }
6282
6283    // ---- Home screen -----------------------------------------------------
6284
6285    /// Schema for a home-screen entry, read from Parquet metadata and memoised for
6286    /// the session. `None` means "not knowable without a scan", which the UI reports
6287    /// rather than papering over.
6288    pub fn home_schema(&mut self, entry: &discover::Entry) -> Option<discover::SchemaPreview> {
6289        // A schema preview reads a local file. Nothing in an object store is read before
6290        // it is opened: asking would only come back empty, again on every rebuild.
6291        if home::is_cloud_place(&entry.path) || home::is_object_store_url(&entry.path) {
6292            return None;
6293        }
6294        if let Some(cached) = self.home_schema_cache.get(&entry.path) {
6295            return cached.clone();
6296        }
6297        // Reading a schema opens a file, so it is requested rather than done here.
6298        // Until it arrives the preview says so; it never blocks the frame.
6299        self.request_home_schema(entry.clone());
6300        None
6301    }
6302
6303    /// What a home-screen worker owes in place of its answer if it panics.
6304    fn owed_answer(&mut self, instead: AppEvent) -> OwedAnswer {
6305        OwedAnswer {
6306            tx: self.events.clone(),
6307            #[cfg(test)]
6308            dies: self
6309                .home_worker_dies
6310                .as_mut()
6311                .is_some_and(|dies| dies(&instead)),
6312            instead: Some(instead),
6313        }
6314    }
6315
6316    /// List the directory the `~` prompt is typing, when it is not the one listed. A
6317    /// URL is listed from what the screen already knows; a local directory is read on
6318    /// a worker.
6319    fn list_the_typed_directory(&mut self) {
6320        if !self.home.path_input_active {
6321            return;
6322        }
6323        let dir = home::typed_dir(&self.home.path_input).to_string();
6324        if self
6325            .home
6326            .path_listing
6327            .as_ref()
6328            .is_some_and(|l| l.dir == dir)
6329        {
6330            return;
6331        }
6332        if home::typed_dir_is_url(&dir) {
6333            self.home.path_listing = Some(home::names_under(&dir, self.home.known_urls()));
6334            return;
6335        }
6336        // Read off the UI thread: a typed path is where a dead mount gets named.
6337        let tx = self.events.clone();
6338        let owed = self.owed_answer(AppEvent::HomePathListed {
6339            listing: Box::new(home::PathListing {
6340                dir: dir.clone(),
6341                names: Vec::new(),
6342                failed: true,
6343            }),
6344        });
6345        std::thread::spawn(move || {
6346            owed.run(|| {
6347                let listing = home::list_typed_dir(&dir);
6348                let _ = tx.send(AppEvent::HomePathListed {
6349                    listing: Box::new(listing),
6350                });
6351            })
6352        });
6353    }
6354
6355    /// Complete the path being typed, on a worker.
6356    fn request_path_completion(&mut self) {
6357        let typed = self.home.path_input.clone();
6358        if typed.is_empty() {
6359            return;
6360        }
6361        let generation = self.home_generation;
6362        let tx = self.events.clone();
6363        std::thread::spawn(move || {
6364            let (completed, candidates) = home::complete_path(&typed);
6365            let _ = tx.send(AppEvent::HomePathCompleted {
6366                generation,
6367                typed,
6368                completed,
6369                candidates,
6370            });
6371        });
6372    }
6373
6374    /// The first rows of a home-screen file for its `ROWS` preview, read on a worker
6375    /// the way its open reads them. `None` until they land, and for a row that is not
6376    /// previewed. `screen_height` sizes the page to the one the table will ask for.
6377    pub fn home_preview_rows(
6378        &mut self,
6379        entry: &discover::Entry,
6380        screen_height: u16,
6381    ) -> Option<Arc<crate::home_preview::PreviewRows>> {
6382        let max = self.app_config.home.preview_max.bytes();
6383        if !crate::home_preview::previewable(entry, max) {
6384            return None;
6385        }
6386        let stamp = crate::home_preview::Stamp::of_entry(entry);
6387        if let Some(known) = self.home_previews.rows(&entry.path, stamp) {
6388            return known;
6389        }
6390        if self.home_previews.inflight.is_none() {
6391            self.request_home_preview(entry.path.clone(), stamp, screen_height);
6392        }
6393        None
6394    }
6395
6396    /// Whether `entry` is one the preview reads, before its rows are in.
6397    pub fn home_preview_pending(&self, path: &Path) -> bool {
6398        self.home_previews.reading(path)
6399    }
6400
6401    /// Read a file's first page on a worker, through the open's own scan and schema
6402    /// read, so the open can install what it built.
6403    fn request_home_preview(
6404        &mut self,
6405        path: PathBuf,
6406        stamp: crate::home_preview::Stamp,
6407        screen_height: u16,
6408    ) {
6409        self.home_previews.inflight = Some(path.clone());
6410        self.reads.previews += 1;
6411        let tx = self.events.clone();
6412        let cloud = self.app_config.cloud.clone();
6413        let formats = self.formats.clone();
6414        let runtime = self.runtime.clone();
6415        let cache = self.cache.clone();
6416        let writes = self.cache_writes.clone();
6417        // The table's rows: the screen less the title, the header and the control bar.
6418        let visible = (screen_height as usize).saturating_sub(3).max(1);
6419        let owed = self.owed_answer(AppEvent::HomePreviewReady {
6420            path: path.clone(),
6421            stamp,
6422            read_at: None,
6423            rows: None,
6424            prepared: crate::home_preview::Handoff::default(),
6425        });
6426        self.runtime.spawn_blocking(move || {
6427            owed.run(|| {
6428                let began = std::time::Instant::now();
6429                let read_at = crate::home_preview::Stamp::of_file(&path);
6430                let read = Self::read_home_preview(
6431                    &path, &cloud, &formats, &runtime, cache, writes, visible,
6432                );
6433                log::debug!(
6434                    target: "datui",
6435                    "home preview of {}: {:.1?}",
6436                    path.display(),
6437                    began.elapsed()
6438                );
6439                let (rows, prepared) = match read {
6440                    Some((rows, prepared)) => (Some(Arc::new(rows)), Some(Box::new(prepared))),
6441                    None => (None, None),
6442                };
6443                let _ = tx.send(AppEvent::HomePreviewReady {
6444                    path,
6445                    stamp,
6446                    read_at,
6447                    rows,
6448                    prepared: Arc::new(Mutex::new(prepared)),
6449                });
6450            })
6451        });
6452    }
6453
6454    /// What the open of `path` from the home screen reads first: its scan, its schema
6455    /// and the page the table asks for when `visible` rows show. Built by the open's
6456    /// own steps with the options the home screen opens a file with, so the dataset is
6457    /// the one the open would build.
6458    fn read_home_preview(
6459        path: &Path,
6460        cloud: &crate::config::CloudConfig,
6461        formats: &crate::formats::Registry,
6462        runtime: &tokio::runtime::Handle,
6463        cache: CacheManager,
6464        writes: CacheWrites,
6465        visible: usize,
6466    ) -> Option<(
6467        crate::home_preview::PreviewRows,
6468        crate::home_preview::Prepared,
6469    )> {
6470        let paths = [path.to_path_buf()];
6471        let scanned = Self::scan_for_open(
6472            cloud,
6473            formats,
6474            &paths,
6475            OpenOptions::default(),
6476            Some(path.to_path_buf()),
6477        )
6478        .ok()?;
6479        let loading::LoadAnswer::Scanned { lf, path, options } = scanned else {
6480            return None;
6481        };
6482        let progress = Arc::<crate::schema_union::FooterProgress>::default();
6483        let report = crate::measurements::OpenReport {
6484            progress: progress.clone(),
6485            meter: Arc::new(crate::measurements::Meter::default()),
6486            remembered: Some(cache),
6487            writes,
6488        };
6489        let read = Self::read_schema_for_open(
6490            *lf,
6491            path,
6492            options,
6493            cloud,
6494            runtime,
6495            &report,
6496            loading::Made::default(),
6497        )
6498        .ok()?;
6499        let loading::LoadAnswer::SchemaRead {
6500            mut state,
6501            options,
6502            debug_label,
6503            ..
6504        } = read
6505        else {
6506            return None;
6507        };
6508        // Planned as the table plans its first page, so the page is the one it wants.
6509        state.visible_rows = visible;
6510        let began = std::time::Instant::now();
6511        let request = state.prepare_async_collect(None)?;
6512        let df = crate::statistics::collect_lazy(request.lf, request.polars_streaming).ok()?;
6513        let result = request.plan.fit(df);
6514        let rows = crate::home_preview::PreviewRows::from_frame(result.rows());
6515        state.measurements().read_page(began.elapsed(), Some(1));
6516        state.apply_async_collect(result);
6517        Some((
6518            rows,
6519            crate::home_preview::Prepared {
6520                state,
6521                options,
6522                debug_label,
6523                progress,
6524            },
6525        ))
6526    }
6527
6528    /// Whether a schema read is currently out for this path.
6529    pub fn home_schema_pending(&self, path: &Path) -> bool {
6530        self.home_schema_inflight.iter().any(|p| p == path)
6531    }
6532
6533    /// Read the selected dataset's schema on a worker.
6534    fn request_home_schema(&mut self, entry: discover::Entry) {
6535        if self.home_schema_inflight.contains(&entry.path) {
6536            return;
6537        }
6538        self.home_schema_inflight.push(entry.path.clone());
6539
6540        let generation = self.home_generation;
6541        let tx = self.events.clone();
6542        // Remembered as having none, so the preview is not asked for again.
6543        let owed = self.owed_answer(AppEvent::HomeSchemaReady {
6544            generation,
6545            path: entry.path.clone(),
6546            preview: None,
6547        });
6548        self.runtime.spawn_blocking(move || {
6549            owed.run(|| {
6550                let preview = discover::schema_preview(&entry);
6551                let _ = tx.send(AppEvent::HomeSchemaReady {
6552                    generation,
6553                    path: entry.path,
6554                    preview,
6555                });
6556            })
6557        });
6558    }
6559
6560    /// Start listing any network roots that have not answered yet.
6561    ///
6562    /// Nothing here waits on the result. A share that has gone away leaves its thread
6563    /// blocked in the kernel — on a `hard` NFS mount that is uninterruptible and the
6564    /// thread never returns — so the task is abandoned rather than joined, exactly as
6565    /// an abandoned dataset load is.
6566    fn spawn_home_probes(&mut self) {
6567        self.stop_listings_left_behind();
6568        for root in self.home.pending_probes() {
6569            if self.home_probes_inflight.contains(&root) {
6570                // Left and come back to before its next page: it goes on.
6571                if let Some(cancelled) = self.home_listing_cancels.get(&root) {
6572                    cancelled.store(false, std::sync::atomic::Ordering::Relaxed);
6573                }
6574                continue;
6575            }
6576            // Each probe of an unreachable share costs a thread that will never come
6577            // back. A handful is a rounding error; an unbounded number, on a machine
6578            // with a page of dead mounts, is not.
6579            //
6580            // Except the directory browsed into, which is the whole screen and has
6581            // nothing else to show. Held behind the cap, it waited on roots the user
6582            // had left — a few slow bucket listings kept a share's directory on a
6583            // spinner long after it could have been read. One more thread per
6584            // directory the user opens is bounded by the user.
6585            let browsed = self.home.browsing.as_ref() == Some(&root);
6586            if !browsed && self.home_probes_inflight.len() >= MAX_CONCURRENT_PROBES {
6587                continue;
6588            }
6589            self.home_probes_inflight.push(root.clone());
6590            let tx = self.events.clone();
6591            let cache = self.cache.clone();
6592            let owed = self.owed_answer(AppEvent::HomeProbeFailed {
6593                root: root.clone(),
6594                message: "Could not read it; see the log".to_string(),
6595            });
6596            #[cfg(feature = "cloud")]
6597            let cloud = self.app_config.cloud.clone();
6598            #[cfg(feature = "cloud")]
6599            let runtime = self.runtime.clone();
6600            #[cfg(feature = "cloud")]
6601            let cancelled = {
6602                let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
6603                self.home_listing_cancels.insert(root.clone(), flag.clone());
6604                flag
6605            };
6606            // A detached OS thread, not the runtime's blocking pool. A thread wedged
6607            // on an unreachable `hard` mount never returns, and the pool is shared with
6608            // the work that actually loads data — a few dead shares must not eat into
6609            // the capacity that opening a dataset depends on.
6610            std::thread::spawn(move || {
6611                owed.run(|| {
6612                    // A bucket or a prefix inside one. It looks like a network root to
6613                    // everything above, and it is, but it is read with an object-store
6614                    // listing rather than `read_dir` — which on a `gs://` path fails, which
6615                    // is why descending into a bucket used to show nothing at all.
6616                    //
6617                    // Deliberately metadata-only. A delimited listing returns names, sizes
6618                    // and modification times for one level, and nothing here reads an
6619                    // object's contents: no footers, no schemas, no row counts. Those are
6620                    // what a local listing fills in for free from bytes already on the
6621                    // machine, and what would cost a ranged read per row against an object
6622                    // store somebody pays egress on.
6623                    #[cfg(feature = "cloud")]
6624                    if let Some((id, account)) = home::cloud_account(&root) {
6625                        let listed = wait_on_runtime(&runtime, async move {
6626                            crate::cloud_browse::list_account(&id, &account, &cloud).await
6627                        });
6628                        match listed {
6629                            Some(Ok(rows)) => {
6630                                let _ = tx.send(AppEvent::HomeProbeReady {
6631                                    root,
6632                                    rows: Some(rows),
6633                                    cut_short: false,
6634                                });
6635                            }
6636                            Some(Err(message)) => {
6637                                log::warn!(
6638                                    target: "datui::cloud",
6639                                    "listing {} failed: {message}",
6640                                    root.display()
6641                                );
6642                                let _ = tx.send(AppEvent::HomeProbeFailed { root, message });
6643                            }
6644                            None => {
6645                                let _ = tx.send(AppEvent::HomeProbeReady {
6646                                    root,
6647                                    rows: None,
6648                                    cut_short: false,
6649                                });
6650                            }
6651                        }
6652                        return;
6653                    }
6654                    #[cfg(feature = "cloud")]
6655                    if crate::cloud_browse::split_bucket_url(&root.to_string_lossy()).is_some()
6656                        || source::azure_parts(&root.to_string_lossy()).is_some()
6657                    {
6658                        let url = root.to_string_lossy().into_owned();
6659                        // Each page's rows are drawn as they come, and leaving the place
6660                        // stops the listing before its next page.
6661                        let watch = crate::cloud_browse::Watch {
6662                            progress: Some(std::sync::Arc::new({
6663                                let (tx, root) = (tx.clone(), root.clone());
6664                                move |so_far: &[crate::discover::Entry]| {
6665                                    let _ = tx.send(AppEvent::HomeProbeProgress {
6666                                        root: root.clone(),
6667                                        rows: so_far.to_vec(),
6668                                    });
6669                                }
6670                            })),
6671                            cancelled,
6672                            names_from: None,
6673                        };
6674                        let listed = wait_on_runtime(&runtime, async move {
6675                            crate::cloud_browse::list_objects_watched(&url, &cloud, &watch).await
6676                        });
6677                        // A refused listing says why, rather than reading as a place that
6678                        // stopped answering.
6679                        match listed {
6680                            Some(Err(message)) => {
6681                                log::warn!(
6682                                    target: "datui::cloud",
6683                                    "listing {} failed: {message}",
6684                                    root.display()
6685                                );
6686                                let _ = tx.send(AppEvent::HomeProbeFailed { root, message });
6687                            }
6688                            Some(Ok(level)) if level.cancelled => {
6689                                let _ = tx.send(AppEvent::HomeProbeCancelled { root });
6690                            }
6691                            other => {
6692                                let (rows, cut_short) = match other {
6693                                    Some(Ok(level)) => (Some(level.rows), level.truncated),
6694                                    _ => (None, false),
6695                                };
6696                                let _ = tx.send(AppEvent::HomeProbeReady {
6697                                    root,
6698                                    rows,
6699                                    cut_short,
6700                                });
6701                            }
6702                        }
6703                        return;
6704                    }
6705                    // Nothing to list a bucket with, and `read_dir` on its URL would
6706                    // only call it unavailable.
6707                    #[cfg(not(feature = "cloud"))]
6708                    if source::is_remote_url(&root) {
6709                        let message = "cloud support not in this build".to_string();
6710                        let _ = tx.send(AppEvent::HomeProbeFailed { root, message });
6711                        return;
6712                    }
6713                    let mut cut_short = false;
6714                    let rows = if std::fs::read_dir(&root).is_ok() {
6715                        // What has been read shows while the rest is read: a share can take
6716                        // seconds over a directory of thousands.
6717                        let scan = crate::discover::scan_dir_progressive(&root, |so_far| {
6718                            let _ = tx.send(AppEvent::HomeProbeProgress {
6719                                root: root.clone(),
6720                                rows: so_far.to_vec(),
6721                            });
6722                        });
6723                        cut_short = scan.truncated;
6724                        let mut rows = scan.entries;
6725                        // Measuring happens here too: it is the same remote filesystem,
6726                        // and this thread is already the one allowed to block on it.
6727                        for row in rows.iter_mut().take(PROBE_MEASURE_LIMIT) {
6728                            crate::discover::enrich(row);
6729                        }
6730                        // Remote datasets are measured nowhere else, so this is the only
6731                        // chance to remember them. Without it a remote row is blank on
6732                        // every run, which is exactly backwards: the hardest things to
6733                        // reach are the ones most worth remembering.
6734                        let mounts = crate::locality::Mounts::current();
6735                        for row in rows.iter_mut() {
6736                            row.cost.source = Some(mounts.describe(&row.path).fstype);
6737                        }
6738                        let facts: Vec<_> = rows.iter().filter_map(home::facts_for).collect();
6739                        cache.record_dataset_facts(&facts);
6740                        Some(rows)
6741                    } else {
6742                        None
6743                    };
6744                    let _ = tx.send(AppEvent::HomeProbeReady {
6745                        root,
6746                        rows,
6747                        cut_short,
6748                    });
6749                })
6750            });
6751        }
6752    }
6753
6754    /// Stop the cloud listings of places no longer on screen: the one browsed, or the
6755    /// roots of the home listing. One left and come back to is listed again.
6756    fn stop_listings_left_behind(&mut self) {
6757        let home = &self.home;
6758        for (root, cancelled) in &self.home_listing_cancels {
6759            let wanted = match &home.browsing {
6760                Some(dir) => dir == root,
6761                None => home
6762                    .sections
6763                    .iter()
6764                    .any(|s| s.remote_root.as_ref() == Some(root)),
6765            };
6766            if !wanted {
6767                cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
6768            }
6769        }
6770        if let Some((dir, _, cancelled)) = &self.home_narrowing
6771            && home.browsing.as_ref() != Some(dir)
6772        {
6773            cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
6774            self.home_narrowing = None;
6775        }
6776        if self
6777            .home
6778            .narrowed
6779            .as_ref()
6780            .is_some_and(|n| self.home.browsing.as_ref() != Some(&n.dir))
6781        {
6782            self.home.narrowed = None;
6783        }
6784    }
6785
6786    /// In a cloud directory cut short at the cap, ask the server for the names the
6787    /// filter starts, so a name past the first few thousand can still be found. Nothing
6788    /// asked when the filter is empty or what is held already answers it.
6789    #[cfg(feature = "cloud")]
6790    fn narrow_cloud_listing(&mut self) {
6791        let dir = self.home.browsing.clone();
6792        let prefix = dir.as_ref().and_then(|dir| {
6793            if !self.home.cut_short.contains(dir) {
6794                return None;
6795            }
6796            let rows = self.home.probed.get(dir)?;
6797            let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
6798            crate::cloud_browse::narrowing_prefix(&self.home.filter, &names)
6799        });
6800        let (Some(dir), Some(prefix)) = (dir, prefix) else {
6801            if let Some((_, _, cancelled)) = self.home_narrowing.take() {
6802                cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
6803            }
6804            if self.home.narrowed.take().is_some() {
6805                self.home_refresh();
6806            }
6807            return;
6808        };
6809        // Everything under a shorter prefix is everything under this one too.
6810        if self.home.narrowed.as_ref().is_some_and(|n| {
6811            n.dir == dir && (n.prefix == prefix || (!n.truncated && prefix.starts_with(&n.prefix)))
6812        }) {
6813            return;
6814        }
6815        if let Some((d, p, _)) = &self.home_narrowing
6816            && *d == dir
6817            && *p == prefix
6818        {
6819            return;
6820        }
6821        if let Some((_, _, cancelled)) = self.home_narrowing.take() {
6822            cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
6823        }
6824        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
6825        self.home_narrowing = Some((dir.clone(), prefix.clone(), cancelled.clone()));
6826        let tx = self.events.clone();
6827        let owed = self.owed_answer(AppEvent::HomeNarrowed {
6828            dir: dir.clone(),
6829            prefix: prefix.clone(),
6830            listed: None,
6831        });
6832        let cloud = self.app_config.cloud.clone();
6833        let runtime = self.runtime.clone();
6834        std::thread::spawn(move || {
6835            owed.run(|| {
6836                let url = dir.to_string_lossy().into_owned();
6837                let watch = crate::cloud_browse::Watch {
6838                    progress: None,
6839                    cancelled,
6840                    names_from: Some(prefix.clone()),
6841                };
6842                let listed = wait_on_runtime(&runtime, async move {
6843                    crate::cloud_browse::list_objects_watched(&url, &cloud, &watch).await
6844                });
6845                let listed = match listed {
6846                    Some(Ok(level)) if !level.cancelled => Some((level.rows, level.truncated)),
6847                    _ => None,
6848                };
6849                let _ = tx.send(AppEvent::HomeNarrowed {
6850                    dir,
6851                    prefix,
6852                    listed,
6853                });
6854            })
6855        });
6856    }
6857
6858    /// Find the cloud sources this machine and the config describe, and list their
6859    /// buckets when `[cloud] list_on_start` asks. Once per session; Ctrl+R asks again.
6860    #[cfg(feature = "cloud")]
6861    fn spawn_cloud_discovery(&mut self) {
6862        if self.cloud_discovery_started {
6863            return;
6864        }
6865        self.cloud_discovery_started = true;
6866        let list = self.app_config.cloud.list_on_start;
6867        self.list_cloud_sources(None, list);
6868    }
6869
6870    /// List the source being browsed, when it has not been asked this session.
6871    /// Entering a source is the request to list it.
6872    #[cfg(feature = "cloud")]
6873    fn list_browsed_cloud_source(&mut self) {
6874        let Some(id) = self
6875            .home
6876            .browsing
6877            .as_deref()
6878            .and_then(home::cloud_source_id)
6879        else {
6880            return;
6881        };
6882        let Some(source) = self.home.cloud.iter_mut().find(|s| s.id == id) else {
6883            return;
6884        };
6885        if source.asked {
6886            return;
6887        }
6888        source.begin_listing();
6889        self.list_cloud_sources(Some(id), true);
6890    }
6891
6892    /// Send the rows of every source, or list the buckets of the one named.
6893    ///
6894    /// The rows go out first, filled from the last run's listing when the source still
6895    /// points at the same place, so the home screen has its counts before any request
6896    /// is made. With `list`, the sources are then listed side by side, a few at a
6897    /// time, and each result is sent the moment it arrives. Without it nothing leaves
6898    /// the machine: no request, and no credential command.
6899    ///
6900    /// Runs on the runtime rather than a detached thread. Unlike a probe of a dead
6901    /// `hard` mount, an HTTP request cannot wedge forever: every call here is bounded
6902    /// by a global timeout, so the task is guaranteed to end.
6903    #[cfg(feature = "cloud")]
6904    fn list_cloud_sources(&mut self, only: Option<String>, list: bool) {
6905        let tx = self.events.clone();
6906        let cloud = self.app_config.cloud.clone();
6907        let cache = self.cache.clone();
6908        self.runtime.spawn(async move {
6909            let mut hidden = cache.load_hidden_cloud_sources();
6910            hidden.extend(cloud.hide.iter().cloned());
6911            let cached_for = |source: &crate::cloud_sources::Source| {
6912                cache.cloud_listing(&source.id, &source.fingerprint())
6913            };
6914            let found = {
6915                let env = crate::cloud_browse::Environment::current();
6916                crate::cloud_sources::discover(&cloud, &env)
6917            };
6918            // Shown or not: a bucket under Recent opens with the login that listed it
6919            // whatever `discover` says. Not a hidden source, which may be hidden for a
6920            // login that no longer works; the default login opens its buckets instead.
6921            // Only with the rows, so an old listing never overrides one made since.
6922            if only.is_none() {
6923                for source in found.iter().filter(|s| !hidden.contains(&s.id)) {
6924                    if let Some(cached) = cached_for(source) {
6925                        crate::cloud_sources::remember_listed(source, &cached.buckets);
6926                    }
6927                }
6928            }
6929            let sources: Vec<crate::cloud_sources::Source> =
6930                crate::cloud_sources::on_home(found, &cloud)
6931                    .into_iter()
6932                    .filter(|s| !hidden.contains(&s.id))
6933                    .collect();
6934
6935            match &only {
6936                None => {
6937                    let rows = sources
6938                        .iter()
6939                        .map(|source| home_cloud_source(source, cached_for(source).as_ref(), list))
6940                        .collect();
6941                    let _ = tx.send(AppEvent::HomeCloudSources { sources: rows });
6942                }
6943                // Gone since its row was drawn: a profile removed, a source hidden
6944                // elsewhere. Said, so the row does not wait on an answer never coming.
6945                Some(id) if !sources.iter().any(|s| &s.id == id) => {
6946                    let _ = tx.send(AppEvent::HomeCloudListed {
6947                        id: id.clone(),
6948                        buckets: Vec::new(),
6949                        details: Vec::new(),
6950                        failure: Some((
6951                            "not found".to_string(),
6952                            format!("{id} is gone or hidden. Ctrl+R at the top looks again."),
6953                        )),
6954                        listed_at: std::time::SystemTime::now(),
6955                    });
6956                    return;
6957                }
6958                Some(_) => {}
6959            }
6960            if !list {
6961                return;
6962            }
6963
6964            // Enough to keep one slow endpoint from delaying the rest, few enough that a
6965            // long list of sources does not open a connection storm.
6966            const LISTING_AT_ONCE: usize = 4;
6967            let permits = std::sync::Arc::new(tokio::sync::Semaphore::new(LISTING_AT_ONCE));
6968            let mut listings = tokio::task::JoinSet::new();
6969            for source in sources
6970                .into_iter()
6971                .filter(|s| only.as_ref().is_none_or(|id| &s.id == id))
6972            {
6973                let permits = permits.clone();
6974                listings.spawn(async move {
6975                    let _permit = permits.acquire_owned().await;
6976                    let result = crate::cloud_browse::list_first_level(&source).await;
6977                    (source, result)
6978                });
6979            }
6980            while let Some(joined) = listings.join_next().await {
6981                let Ok((source, result)) = joined else {
6982                    continue;
6983                };
6984                let listed_at = std::time::SystemTime::now();
6985                // Buckets named in the config are shown whether or not the login can
6986                // list them; that is what naming them is for.
6987                let mut names = source.buckets.clone();
6988                let mut details = Vec::new();
6989                let failure = match result {
6990                    Ok(listed) => {
6991                        for item in listed {
6992                            crate::cloud_sources::remember_bucket(&source, &item.name);
6993                            if !item.details.is_empty() {
6994                                details.push((item.place.clone(), item.details));
6995                            }
6996                            let name = item.name;
6997                            if !names.contains(&name) {
6998                                names.push(name);
6999                            }
7000                        }
7001                        cache.save_cloud_listing(
7002                            &source.id,
7003                            crate::cache::CloudListing {
7004                                fingerprint: source.fingerprint(),
7005                                buckets: names.clone(),
7006                                listed_at: listed_at
7007                                    .duration_since(std::time::UNIX_EPOCH)
7008                                    .map(|d| d.as_secs())
7009                                    .unwrap_or(0),
7010                            },
7011                        );
7012                        None
7013                    }
7014                    Err(e) => {
7015                        log::warn!(target: "datui::cloud", "listing {} failed: {e}", source.id);
7016                        Some(summarize_cloud_failure(&e))
7017                    }
7018                };
7019                let _ = tx.send(AppEvent::HomeCloudListed {
7020                    id: source.id.clone(),
7021                    buckets: names
7022                        .iter()
7023                        .map(|b| PathBuf::from(source.bucket_url(b)))
7024                        .collect(),
7025                    details,
7026                    failure,
7027                    listed_at,
7028                });
7029            }
7030        });
7031    }
7032
7033    /// Ask again for what is on screen, ignoring what is cached: the buckets of the
7034    /// source being browsed, the contents of the bucket or directory being browsed, or
7035    /// every source's buckets from the home listing.
7036    fn home_reload(&mut self) {
7037        #[cfg(feature = "cloud")]
7038        {
7039            let browsing = self.home.browsing.clone();
7040            match browsing.as_deref().and_then(home::cloud_source_id) {
7041                Some(id) => {
7042                    if let Some(source) = self.home.cloud.iter_mut().find(|s| s.id == id) {
7043                        source.begin_listing();
7044                    }
7045                    self.list_cloud_sources(Some(id), true);
7046                }
7047                None if browsing.is_none() && !self.home.cloud.is_empty() => {
7048                    for source in &mut self.home.cloud {
7049                        source.begin_listing();
7050                    }
7051                    self.list_cloud_sources(None, true);
7052                }
7053                None => {}
7054            }
7055        }
7056        if let Some(dir) = self.home.browsing.clone() {
7057            self.home.probed.remove(&dir);
7058            self.home.unreachable.remove(&dir);
7059            self.home.cut_short.remove(&dir);
7060        }
7061        // A peek that failed is asked again: Ctrl+R is the request to try. So is a web
7062        // file that was not there.
7063        self.home.peek_failed.clear();
7064        for path in std::mem::take(&mut self.home.web_gone).into_keys() {
7065            self.home.sized.remove(&path);
7066        }
7067        self.home.status = None;
7068        self.home_refresh();
7069    }
7070
7071    /// Start the recursive search below the working directory, if it is wanted and
7072    /// not already running.
7073    ///
7074    /// Triggered by typing rather than by opening the home screen: typing is the
7075    /// signal that someone is looking for something. Launching datui, pressing Enter
7076    /// on a recent dataset and leaving costs no walk at all.
7077    fn spawn_home_search(&mut self) {
7078        if self.home_search_inflight || self.home.search.done {
7079            return;
7080        }
7081        let config = self.app_config.home.search.clone();
7082        if !config.enabled {
7083            return;
7084        }
7085        let Some(root) =
7086            crate::search::search_root(self.home.browsing.as_ref(), self.home.network_check)
7087        else {
7088            return;
7089        };
7090
7091        self.home.search.reset();
7092        self.home.search.root = Some(root.clone());
7093        self.home.search.running = true;
7094        self.home.search.epoch = next_search_epoch();
7095        self.home.search_limit = config.max_results;
7096        self.home_search_inflight = true;
7097
7098        let generation = self.home_generation;
7099        self.home_search_generation = generation;
7100        let tx = self.events.clone();
7101        let formats = self.formats.clone();
7102        // Ended, with what the batches already found kept.
7103        let owed = self.owed_answer(AppEvent::HomeSearchDone {
7104            generation,
7105            root: root.clone(),
7106            scanned: 0,
7107            limited: Some("partial · failed".to_string()),
7108        });
7109        // A detached thread for the same reason the probes use one: the walk touches
7110        // a filesystem, and nothing that touches a filesystem may run where a stall
7111        // would stop the screen from drawing.
7112        std::thread::spawn(move || {
7113            owed.run(|| {
7114                let walk_root = root.clone();
7115                let batch_tx = tx.clone();
7116                let batch_gen = generation;
7117                let batch_root = root.clone();
7118                let outcome = crate::search::walk_with_specs(
7119                    &walk_root,
7120                    &config,
7121                    &formats,
7122                    move |found, outcome| {
7123                        // Sent even when empty: it carries the progress count, and it is the
7124                        // only place the walk learns that nobody is listening any more.
7125                        batch_tx
7126                            .send(AppEvent::HomeSearchBatch {
7127                                generation: batch_gen,
7128                                root: batch_root.clone(),
7129                                found,
7130                                scanned: outcome.scanned,
7131                            })
7132                            // A closed channel means the app is gone; stop walking.
7133                            .is_ok()
7134                    },
7135                );
7136                let _ = tx.send(AppEvent::HomeSearchDone {
7137                    generation,
7138                    root,
7139                    scanned: outcome.scanned,
7140                    limited: outcome.note().map(str::to_string),
7141                });
7142            })
7143        });
7144    }
7145
7146    /// Score the filter against the search's files on a worker, when a scoring is owed.
7147    ///
7148    /// Asked after every event. Over a tree of tens of thousands of files the scoring
7149    /// is what held each keystroke's echo back, so it runs where a stall cannot hold
7150    /// the screen, one at a time; each answer asks for the next if the filter moved on.
7151    fn home_score_search(&mut self) {
7152        if self.input_mode != InputMode::Home {
7153            return;
7154        }
7155        let Some(job) = self.home.score_job() else {
7156            return;
7157        };
7158        let epoch = job.epoch;
7159        let tx = self.events.clone();
7160        // A worker that dies answers with nothing.
7161        let owed = self.owed_answer(AppEvent::HomeSearchScored {
7162            epoch,
7163            matches: None,
7164        });
7165        self.runtime.spawn_blocking(move || {
7166            owed.run(move || {
7167                let matches =
7168                    crate::search::score(&job.results, &job.query, job.base.as_ref(), job.limit);
7169                let _ = tx.send(AppEvent::HomeSearchScored {
7170                    epoch,
7171                    matches: Some(Box::new(matches)),
7172                });
7173            })
7174        });
7175    }
7176
7177    /// Rebuild the home listing from the filesystem.
7178    fn home_refresh(&mut self) {
7179        // Every way into a source comes through here: Enter, Backspace up from a
7180        // bucket, a jump, and rows arriving while the source is already open.
7181        #[cfg(feature = "cloud")]
7182        self.list_browsed_cloud_source();
7183        // Somewhere else now, a listing of where the user was is pages for nobody.
7184        self.stop_listings_left_behind();
7185        self.home_generation = self.home_generation.wrapping_add(1);
7186        let generation = self.home_generation;
7187
7188        self.move_remembered_places();
7189        self.home.catalogs = home::catalogs(&self.app_config);
7190        // Hidden with Delete on its heading: the catalog that comes with datui only,
7191        // never a user's own `examples.toml`.
7192        if self.cache.examples_hidden() {
7193            self.home
7194                .catalogs
7195                .retain(|c| c.origin != crate::catalog::Origin::Bundled);
7196        }
7197        let mut request = home::ListingRequest {
7198            // Filled in on the worker, from the cache and the desktop's recents: files
7199            // all the same, and the first frame does not wait on a file.
7200            recents: Vec::new(),
7201            desktop_dirs: Vec::new(),
7202            browsing: self.home.browsing.clone(),
7203            probed: self.home.probed.clone(),
7204            unreachable: self.home.unreachable.clone(),
7205            listing_so_far: self.home.listing_so_far.clone(),
7206            cut_short: self.home.cut_short.clone(),
7207            narrowed: self.home.narrowed.clone(),
7208            probe_errors: self.home.probe_errors.clone(),
7209            network_check: self.home.network_check,
7210            cloud: self.home.cloud.clone(),
7211            catalogs: self.home.catalogs.clone(),
7212            known: Default::default(),
7213            formats: self.formats.clone(),
7214        };
7215        let read_folds = std::mem::take(&mut self.home.folds_owed);
7216        let desktop = self.app_config.home.desktop_recents;
7217        let cache = self.cache.clone();
7218        let writes = self.cache_writes.clone();
7219
7220        self.home.listing_in_flight = true;
7221        let tx = self.events.clone();
7222        let owed = self.owed_answer(AppEvent::HomeListingFailed);
7223        self.runtime.spawn_blocking(move || {
7224            owed.run(move || {
7225                // After the dataset just left is in the recents with its shape.
7226                writes.settle();
7227                // Ranked by frecency; the newest is where the cursor lands, so the
7228                // last file is still one Enter away.
7229                let (recents, visits) = cache.load_recents_with_visits();
7230                let newest = recents.first().cloned();
7231                request.recents = crate::cache::by_frecency(recents, &visits);
7232                request.known = cache.load_dataset_facts();
7233                if desktop {
7234                    request.desktop_dirs = home::desktop_recent_dirs();
7235                }
7236                let listing = home::build_listing(&request);
7237                let mut visits = visits;
7238                listing.alias_visits(&mut visits);
7239                // A record shown is a record used: the ones eviction keeps.
7240                let shown: Vec<PathBuf> = listing
7241                    .sections
7242                    .iter()
7243                    .flat_map(|s| s.rows.iter().chain(&s.door))
7244                    .flat_map(|row| [row.path.clone(), home::index_key(&row.path)])
7245                    .filter(|key| request.known.contains_key(key))
7246                    .collect();
7247                cache.touch_dataset_facts(shown.iter().map(PathBuf::as_path));
7248                let _ = tx.send(AppEvent::HomeListingReady {
7249                    generation,
7250                    listing: Box::new(listing),
7251                    known: request.known,
7252                    visits,
7253                    newest,
7254                    folds: read_folds.then(|| cache.load_folds()),
7255                });
7256            })
7257        });
7258    }
7259
7260    /// Ask the worker to measure rows that are on screen and not yet known.
7261    ///
7262    /// Reading a Parquet footer opens a file. That is the call that blocks on a FIFO,
7263    /// a device node, a wedged mount or a failing disk, so it never happens on the
7264    /// thread that draws.
7265    fn request_home_measurements(&mut self) {
7266        if self.home.measure_in_flight {
7267            return;
7268        }
7269        let wanted = self.home.unmeasured_visible(MEASURE_BATCH);
7270        if wanted.is_empty() {
7271            return;
7272        }
7273
7274        self.home.measure_in_flight = true;
7275        let tx = self.events.clone();
7276        let cache = self.cache.clone();
7277        self.runtime.spawn_blocking(move || {
7278            home::look_into_batch(wanted, &cache, |path, m| {
7279                let _ = tx.send(AppEvent::HomeMeasured {
7280                    measured: vec![(path, m)],
7281                    done: false,
7282                });
7283            });
7284            let _ = tx.send(AppEvent::HomeMeasured {
7285                measured: Vec::new(),
7286                done: true,
7287            });
7288        });
7289    }
7290
7291    /// Ask for what the frame just drawn needs and did not have: counts for the rows
7292    /// on screen that have none, and kinds for the rows nothing has looked into.
7293    ///
7294    /// After the frame, never during it. Scrolling is what brings new rows into view,
7295    /// and the reading is a worker's job — this thread only decides what is worth
7296    /// asking about.
7297    pub fn request_what_the_frame_needs(&mut self) {
7298        if self.input_mode == InputMode::Normal {
7299            self.load_ahead();
7300            self.catch_up_follow();
7301        }
7302        self.inspector_needs();
7303        if self.input_mode != InputMode::Home {
7304            return;
7305        }
7306        if std::mem::take(&mut self.home.pending_enrich) {
7307            self.request_home_measurements();
7308        }
7309        #[cfg(feature = "http")]
7310        self.size_selected_web_file();
7311        if std::mem::take(&mut self.home.pending_classify) {
7312            self.request_home_classifications();
7313        }
7314        #[cfg(feature = "cloud")]
7315        if std::mem::take(&mut self.home.pending_peek) {
7316            self.peek_cloud_directories();
7317        }
7318    }
7319
7320    /// Ask an HTTP(S) server what the file under the cursor weighs, once a session, when
7321    /// nothing has measured it: its row shows a catalog's `~33 MB` until the answer
7322    /// lands, and the answer is kept with what datui measured, for the next listing.
7323    #[cfg(feature = "http")]
7324    fn size_selected_web_file(&mut self) {
7325        if !self.head_web_rows {
7326            return;
7327        }
7328        let Some(entry) = self.home.selected_entry() else {
7329            return;
7330        };
7331        if entry.size.is_some()
7332            || !matches!(
7333                source::input_source(&entry.path),
7334                source::InputSource::Http(_)
7335            )
7336            || !self.home.sized.insert(entry.path.clone())
7337        {
7338            return;
7339        }
7340        let tx = self.events.clone();
7341        let cache = self.cache.clone();
7342        self.runtime.spawn_blocking(move || {
7343            let size = match Self::fetch_remote_size_http(&entry.path.to_string_lossy()) {
7344                Ok(Some(size)) => size,
7345                Ok(None) => return,
7346                Err(gone) => {
7347                    let _ = tx.send(AppEvent::HomeWebGone {
7348                        path: entry.path,
7349                        gone,
7350                    });
7351                    return;
7352                }
7353            };
7354            let key = home::index_key(&entry.path);
7355            let mut facts = cache.dataset_facts(&key).unwrap_or_default();
7356            facts.size = size;
7357            cache.record_dataset_facts(&[(key, facts)]);
7358            let measured = home::Measured {
7359                rows: entry.rows,
7360                cols: entry.cols,
7361                cols_sampled: entry.cols_sampled,
7362                size: Some(size),
7363                columns: entry.columns.clone(),
7364                cost: entry.cost.clone(),
7365                kind: None,
7366                holds: entry.holds.clone(),
7367            };
7368            let _ = tx.send(AppEvent::HomeSized {
7369                path: entry.path,
7370                measured,
7371            });
7372        });
7373    }
7374
7375    /// Ask a worker what the rows on screen are.
7376    ///
7377    /// Classifying a row means reading the directory it names, which on a share is a
7378    /// round trip and on a wedged mount never returns — so it happens here rather
7379    /// than while the listing is built, where it was paid for in directory order and
7380    /// bought a label for the first sixty-four rows and a wrong one for the rest.
7381    ///
7382    /// A detached thread, not the runtime's blocking pool, for the reason the probes
7383    /// give: a thread stuck on an unreachable `hard` mount never comes back, and the
7384    /// pool is shared with the work that actually loads data. One at a time, so a
7385    /// share that has stopped answering costs one thread and then stops asking.
7386    ///
7387    /// This measures remote rows as well as classifying them, which
7388    /// [`HomeState::unmeasured_visible`] deliberately refuses to do — it leaves them to
7389    /// their root's probe, so that a share gets one thread and not two. That reasoning
7390    /// no longer reaches: the probe scans a remote directory before anything has looked
7391    /// into it, so every row it returns is `Unknown` and there is nothing for it to
7392    /// measure. This pass is the only thing left that can, and it makes the same bargain
7393    /// the probe made — one detached thread, on a filesystem it is already reading.
7394    fn request_home_classifications(&mut self) {
7395        if self.home.classify_in_flight {
7396            return;
7397        }
7398        let wanted = self.home.unclassified_visible(CLASSIFY_BATCH);
7399        if wanted.is_empty() {
7400            return;
7401        }
7402
7403        self.home.classify_in_flight = true;
7404        let tx = self.events.clone();
7405        let cache = self.cache.clone();
7406        std::thread::spawn(move || {
7407            home::look_into_batch(wanted, &cache, |path, m| {
7408                let _ = tx.send(AppEvent::HomeClassified {
7409                    measured: vec![(path, m)],
7410                    done: false,
7411                });
7412            });
7413            let _ = tx.send(AppEvent::HomeClassified {
7414                measured: Vec::new(),
7415                done: true,
7416            });
7417        });
7418    }
7419
7420    /// Enter the home screen, rebuilding it, abandoning any in-flight load.
7421    ///
7422    /// Returning home puts the cursor on whatever you currently have open, so the
7423    /// round trip out and back lands where you left rather than at the top.
7424    ///
7425    /// Abandoning puts the open in flight down at once ([`loading::Loader::retire`]):
7426    /// its jobs are superseded, so their answers are dropped on arrival and none can
7427    /// install a dataset or take the user off the screen they went to; its stop flag
7428    /// is raised, so a download stops and an in-flight cloud pass stops issuing paid
7429    /// reads within a wave. Work that is not the open's — an export, an analysis, the
7430    /// footer pass of the dataset already on screen — is deliberately left alone, so
7431    /// its progress indicator and its completion modal must survive this.
7432    pub fn abandon_load(&mut self) {
7433        let retired = self.loading.retire();
7434        if let Some(retired) = retired {
7435            self.put_down_load(retired);
7436        }
7437        // A chart being prepared for the dataset we are leaving would otherwise keep
7438        // the throbber up on the home screen, and its result could later land in a
7439        // different dataset with the same column names.
7440        self.reset_chart_state();
7441        // A look that is out belongs to the home screen being left, and the thread it is
7442        // on may never come back — a share that has gone away is the case it exists for.
7443        // Superseded, its answer touches nothing, and the keyboard does not wait for it.
7444        if self.jobs.supersede(|job| matches!(job, Job::Classify(_))) {
7445            self.home.status = None;
7446        }
7447        // And a collect that was waiting behind this load goes with it. Left standing,
7448        // it runs the moment the generation is free — reading the dataset the user
7449        // walked away from, at the home screen, with every key held.
7450        self.jobs.take_owed(Self::owed_rows);
7451        // Only an open's own wait is put down. An export holds keys too, and it keeps
7452        // running. The rows the open's last step is reading still land; nobody waits on
7453        // them.
7454        if retired.is_some() {
7455            self.busy = false;
7456            let quieted = self.jobs.quiet(Self::reading_rows);
7457            if self
7458                .status_message
7459                .as_ref()
7460                .is_some_and(|status| quieted.contains(status))
7461            {
7462                self.status_message = None;
7463            }
7464        }
7465        // Keys typed at the frozen screen were meant for the load, not for home:
7466        // replayed there they could open a dataset nobody asked for.
7467        self.screen_generation = self.screen_generation.wrapping_add(1);
7468    }
7469
7470    pub fn enter_home(&mut self) {
7471        self.pause_indexing();
7472        if self.return_from_quality_evidence(false) {
7473            self.analysis_modal.close();
7474        }
7475        // The view modal keys and renders off its own `active`, not the input
7476        // mode, so left open here it would come back as a zombie over the next
7477        // dataset opened.
7478        self.view_modal.close();
7479        self.inspector_modal.close();
7480        self.stop_find();
7481        self.hex = None;
7482        // A count of the dataset being left is read for nobody.
7483        self.stop_value_count();
7484        self.export_counts = None;
7485        self.abandon_load();
7486        // Nobody is watching the file any more.
7487        if let Some(state) = self.data_table_state.as_mut() {
7488            state.stop_following();
7489        }
7490        self.home.status = None;
7491        // The search that found the dataset comes back, selected: the next character
7492        // typed starts a new one, and `~` opens the path prompt.
7493        self.home.filter_selected = !self.home.filter.is_empty();
7494        self.home.folds_owed = true;
7495        self.home_refresh();
7496        if let Some(open_path) = self.path.clone() {
7497            let target =
7498                crate::canonical::canonicalize(&open_path).unwrap_or_else(|_| open_path.clone());
7499            if let Some(idx) = self.home.visible().iter().position(|row| match row {
7500                home::Row::Entry { entry, .. } => {
7501                    crate::canonical::canonicalize(&entry.path)
7502                        .unwrap_or_else(|_| entry.path.clone())
7503                        == target
7504                }
7505                // Not the door: its path is the directory's, so an open file whose
7506                // directory is being browsed would put the cursor on the row that
7507                // opens the whole directory rather than on the file itself.
7508                home::Row::Header { .. }
7509                | home::Row::Place { .. }
7510                | home::Row::More { .. }
7511                | home::Row::Hidden { .. }
7512                | home::Row::Door { .. } => false,
7513            }) {
7514                self.home.selected = idx;
7515            }
7516        }
7517        self.input_mode = InputMode::Home;
7518    }
7519
7520    /// Esc backs out one layer of context at a time: the filter, then the directory
7521    /// descended into, then back to the data that was open. At the top level it does
7522    /// nothing. It used to quit there, which made a reflexive Esc close the program
7523    /// while the same key one level down merely went up; Ctrl+C quits, from anywhere.
7524    fn home_escape(&mut self) -> Option<AppEvent> {
7525        if !self.home.filter.is_empty() {
7526            self.home.filter.clear();
7527            self.home.sync_search_section();
7528            // On a dataset, as at launch, not on the first section's header.
7529            self.home.select_first_entry();
7530            return None;
7531        }
7532        if self.home.browsing.is_some() {
7533            if self.home.below_browse_start() {
7534                self.home_ascend();
7535            } else {
7536                // Climbing past where the browse began would take Esc somewhere the
7537                // user never was; it returns to the listing they started from instead.
7538                self.home_leave_browsing(None);
7539            }
7540            return None;
7541        }
7542        if self.data_table_state.is_some() {
7543            self.input_mode = InputMode::Normal;
7544            // Said on arrival: Esc pressed once too often to clear the home screen lands
7545            // here, and the keys typed next act on the table (#547 D14).
7546            let name = self
7547                .path
7548                .as_deref()
7549                .and_then(|p| p.file_name())
7550                .map(|n| n.to_string_lossy().into_owned());
7551            if let Some(name) = name {
7552                self.flash_note(format!("Back to {name}"));
7553            }
7554        }
7555        None
7556    }
7557
7558    /// Drop the highlighted dataset from the recents list.
7559    ///
7560    /// Only from the Recent section: a row under a directory is a file on disk, and
7561    /// forgetting it there would either do nothing or imply a deletion datui is not
7562    /// going to perform.
7563    fn home_forget_selected(&mut self) {
7564        // A catalog's heading: Delete hides the one that comes with datui, until the
7565        // cache is cleared. A catalog of the user's is hidden by its id in the config.
7566        if let Some(catalog) = self.home.selected_catalog() {
7567            if catalog.origin == crate::catalog::Origin::Bundled {
7568                let message = format!(
7569                    "Hide {}? It comes back after datui cache clear.",
7570                    catalog.label
7571                );
7572                self.pending_hide_examples = true;
7573                self.confirmation_modal.show_destructive(message, "Hide");
7574            } else {
7575                self.home.status = Some(format!(
7576                    "[home] hide = [\"{}\"] in config.toml hides it",
7577                    catalog.id
7578                ));
7579            }
7580            return;
7581        }
7582        // A place row stands for every recent under it. Forgetting them all is one
7583        // keystroke from forgetting one, so it asks first, the way Shift+Delete does.
7584        if let Some(home::Row::Place { path, held, .. }) = self.home.selected_row() {
7585            self.pending_forget_place = Some(path.clone());
7586            self.confirmation_modal.show(format!(
7587                "Forget {held} recently opened {} under {}?",
7588                if held == 1 { "dataset" } else { "datasets" },
7589                home::display_path(&path)
7590            ));
7591            return;
7592        }
7593        // A row of catalog.toml's own section goes from the file, as Ctrl+D on it does.
7594        // The same place under Recent is a recent, and Delete forgets only that.
7595        let in_mine = self
7596            .home
7597            .selected_section()
7598            .and_then(|i| self.home.sections.get(i))
7599            .is_some_and(|s| s.origin == Some("catalog.toml"));
7600        if in_mine
7601            && let Some((location, _)) = self.home_row_for_catalog()
7602            && let Some((id, name)) = self.mine_entry_at(&location)
7603        {
7604            self.home_forget_from_catalog(&id, &name);
7605            return;
7606        }
7607        let section_title = self
7608            .home
7609            .selected_section()
7610            .and_then(|i| self.home.sections.get(i))
7611            .map(|s| s.title.clone())
7612            .unwrap_or_default();
7613        if self.home.browsing.is_none()
7614            && section_title == home::HomeState::CLOUD_SECTION
7615            && let Some(id) = self
7616                .home
7617                .selected_entry()
7618                .and_then(|e| home::cloud_source_id(&e.path))
7619        {
7620            self.cache.hide_cloud_source(&id);
7621            self.home.cloud.retain(|s| s.id != id);
7622            self.home_refresh();
7623            return;
7624        }
7625        let in_recents = section_title == "Recent";
7626        if !in_recents {
7627            self.home.status = Some("Only recents and catalog.toml rows can be forgotten".into());
7628            return;
7629        }
7630        let Some(entry) = self.home.selected_entry() else {
7631            return;
7632        };
7633        self.cache.forget_recent(&entry.path);
7634        // Nothing to say: the row going is the answer.
7635        self.home.status = None;
7636        self.home_refresh();
7637    }
7638
7639    /// The dataset or directory the highlighted row stands for, as Ctrl+D adds it: its
7640    /// location, and the name its row shows. A heading stands for the directory its
7641    /// section lists. A table inside a file, a cloud source and the rows that are not
7642    /// places have none.
7643    fn home_row_for_catalog(&self) -> Option<(PathBuf, String)> {
7644        match self.home.selected_row()? {
7645            home::Row::Place { path, .. } => {
7646                let name = home::display_path(&path);
7647                Some((path, name))
7648            }
7649            home::Row::Door { entry, .. } => {
7650                // A local door's trailing slash goes; a URL keeps its `//`, which
7651                // `components` would fold into a local path.
7652                let path: PathBuf = if matches!(
7653                    source::input_source(&entry.path),
7654                    source::InputSource::Local(_)
7655                ) {
7656                    entry.path.components().collect()
7657                } else {
7658                    entry.path.clone()
7659                };
7660                Some((path.clone(), home::display_path(&path)))
7661            }
7662            home::Row::Entry { entry, .. } => (entry.table.is_none()
7663                && !home::is_cloud_place(&entry.path)
7664                && crate::members::split(&entry.path).is_none())
7665            .then(|| (entry.path.clone(), entry.name.clone())),
7666            home::Row::Header { section, .. } => {
7667                let root = self.home.sections.get(section)?.root.clone()?;
7668                let name = home::display_path(&root);
7669                Some((root, name))
7670            }
7671            home::Row::More { .. } | home::Row::Hidden { .. } => None,
7672        }
7673    }
7674
7675    /// `catalog.toml`'s path: beside the config file read, else in the config directory.
7676    fn mine_catalog_file(&self) -> Option<PathBuf> {
7677        let dir = match &self.app_config.catalog_dir {
7678            Some(dir) => dir.clone(),
7679            None => config::ConfigManager::new(APP_NAME)
7680                .ok()?
7681                .config_dir()
7682                .to_path_buf(),
7683        };
7684        Some(dir.join(catalog::MINE_FILE))
7685    }
7686
7687    /// The id and name of the `catalog.toml` entry at `location`, when there is one.
7688    fn mine_entry_at(&self, location: &Path) -> Option<(String, String)> {
7689        self.app_config
7690            .read_catalogs
7691            .iter()
7692            .find(|c| c.origin == catalog::Origin::Mine)?
7693            .dataset_at(location)
7694            .map(|d| (d.id.clone(), d.name.clone()))
7695    }
7696
7697    /// Read the catalogs again after `catalog.toml` changed, and list again.
7698    fn reload_catalogs(&mut self) -> Result<(), String> {
7699        let dir = self
7700            .mine_catalog_file()
7701            .and_then(|f| f.parent().map(Path::to_path_buf));
7702        self.app_config
7703            .read_catalog_files(dir.as_deref())
7704            .map_err(|e| e.to_string())?;
7705        // The open dataset's notes and Documentation tab follow the file.
7706        let shown = home::catalogs(&self.app_config);
7707        let path = self.path.clone();
7708        self.codebook = path.as_deref().and_then(|p| home::codebook_for(&shown, p));
7709        self.catalog_entry = path
7710            .as_deref()
7711            .and_then(|p| home::catalog_entry_for(&shown, p));
7712        self.open_info_documentation();
7713        self.home_refresh();
7714        Ok(())
7715    }
7716
7717    /// What Ctrl+D writes for a row at `location` named `name`: a copy of what another
7718    /// catalog says of it, or the place as it is.
7719    fn new_catalog_dataset(&self, location: &Path, name: &str) -> catalog::NewDataset {
7720        if let Some((_, shown)) = self.home.catalog_dataset(location) {
7721            let entry = &shown.entry;
7722            return catalog::NewDataset {
7723                name: entry.name.clone(),
7724                path: entry.local_path().map(|p| home::display_path(&p)),
7725                url: entry.url.clone(),
7726                auth: entry.auth.clone(),
7727                connection: entry.connection.clone(),
7728                description: entry.description.clone(),
7729                size: entry.size,
7730            };
7731        }
7732        let mut new = catalog::NewDataset {
7733            name: name.to_string(),
7734            ..Default::default()
7735        };
7736        if matches!(
7737            source::input_source(location),
7738            source::InputSource::Local(_)
7739        ) {
7740            let absolute = if location.is_relative() {
7741                std::env::current_dir()
7742                    .map(|cwd| cwd.join(location))
7743                    .unwrap_or_else(|_| location.to_path_buf())
7744            } else {
7745                location.to_path_buf()
7746            };
7747            new.path = Some(home::display_path(&absolute));
7748            return new;
7749        }
7750        // A store reached through a named source: the source becomes the connection
7751        // when it is one of the config's, and the URL loses it.
7752        let text = location.to_string_lossy();
7753        let (id, plain) = source::split_source_id(&text);
7754        new.url = Some(plain.into_owned());
7755        if let Some(id) = id {
7756            new.connection = Some(id.to_string());
7757        }
7758        new
7759    }
7760
7761    /// Ctrl+D: add the row under the cursor to `catalog.toml`, or forget it from there.
7762    fn home_toggle_catalog(&mut self) {
7763        let Some((location, name)) = self.home_row_for_catalog() else {
7764            self.home.status = Some("Move to a dataset or directory to add it".into());
7765            return;
7766        };
7767        if let Some((id, name)) = self.mine_entry_at(&location) {
7768            self.home_forget_from_catalog(&id, &name);
7769            return;
7770        }
7771        let Some(file) = self.mine_catalog_file() else {
7772            self.home.status = Some("No config directory to keep catalog.toml in".into());
7773            return;
7774        };
7775        let new = self.new_catalog_dataset(&location, &name);
7776        // A source found on the machine, not one of the config's connections, cannot
7777        // be named in a catalog: without it the URL would be read elsewhere.
7778        if let Some(connection) = &new.connection
7779            && !self
7780                .app_config
7781                .cloud
7782                .connections
7783                .iter()
7784                .any(|c| c.name == *connection)
7785        {
7786            self.home.status = Some(format!(
7787                "Not added: {connection} is not a [[cloud.connections]] entry in the config"
7788            ));
7789            return;
7790        }
7791        if let Err(why) = new.check() {
7792            self.home.status = Some(format!("Not added: {why}"));
7793            return;
7794        }
7795        let label = self
7796            .app_config
7797            .read_catalogs
7798            .iter()
7799            .find(|c| c.origin == catalog::Origin::Mine)
7800            .map(|c| c.label.clone())
7801            .unwrap_or_else(|| catalog::MINE_LABEL.to_string());
7802        match catalog::add(&file, &new) {
7803            Ok(_) => match self.reload_catalogs() {
7804                Ok(()) => self.flash_note(format!("Added {} to {label}", new.name)),
7805                Err(e) => self.error_modal.show(e),
7806            },
7807            Err(e) => self.error_modal.show(e.to_string()),
7808        }
7809    }
7810
7811    /// Remove the entry `id` from `catalog.toml`.
7812    fn home_forget_from_catalog(&mut self, id: &str, name: &str) {
7813        let Some(file) = self.mine_catalog_file() else {
7814            return;
7815        };
7816        match catalog::forget(&file, id) {
7817            Ok(()) => match self.reload_catalogs() {
7818                Ok(()) => self.flash_note(format!("Forgot {name}")),
7819                Err(e) => self.error_modal.show(e),
7820            },
7821            Err(e) => self.error_modal.show(e.to_string()),
7822        }
7823    }
7824
7825    /// Before 0.4.0 Ctrl+D kept directories in the cache. Once, they move into
7826    /// `catalog.toml`, where Ctrl+D keeps them now, and the cache's list goes.
7827    fn move_remembered_places(&mut self) {
7828        if std::mem::replace(&mut self.remembered_moved, true) {
7829            return;
7830        }
7831        let places = self.cache.load_remembered_places();
7832        if places.is_empty() {
7833            return;
7834        }
7835        let Some(file) = self.mine_catalog_file() else {
7836            return;
7837        };
7838        // The cache's list goes only once every place is in catalog.toml: a failure
7839        // leaves it for the next run.
7840        match catalog::move_places(&file, &places) {
7841            Ok(_) => self.cache.clear_remembered_places(),
7842            Err(e) => {
7843                log::warn!(target: "datui", "moving remembered places into catalog.toml: {e:#}")
7844            }
7845        }
7846        let dir = file.parent().map(Path::to_path_buf);
7847        if let Err(e) = self.app_config.read_catalog_files(dir.as_deref()) {
7848            log::warn!(target: "datui", "reading catalog.toml: {e:#}");
7849        }
7850    }
7851
7852    /// Info's Documentation tab for the open dataset: the catalog entry it is, or is
7853    /// inside, with what the format spec that read it says; closed when neither says.
7854    fn open_info_documentation(&mut self) {
7855        self.info_documentation.close();
7856        let spec = self.data_table_state.as_ref().and_then(|state| {
7857            state
7858                .format_read()
7859                .map(|read| read.spec.clone())
7860                .or_else(|| state.delimited_read().map(|read| read.spec.clone()))
7861        });
7862        let name = self
7863            .path
7864            .as_deref()
7865            .and_then(Path::file_name)
7866            .map(|n| n.to_string_lossy().into_owned())
7867            .unwrap_or_default();
7868        if let Some(doc) = widgets::documentation::Documented::new(
7869            self.catalog_entry.clone(),
7870            spec.and_then(|s| s.docs()).map(std::sync::Arc::new),
7871            name,
7872        ) {
7873            self.info_documentation.open(doc, None);
7874            self.info_documentation.links_open = self.local_desktop;
7875        }
7876    }
7877
7878    /// Ctrl+E: the Documentation view of the row under the cursor: the catalog dataset
7879    /// it is or is inside, and what the format spec that reads it says.
7880    fn home_open_documentation(&mut self) {
7881        let Some((path, doc)) = self.home_documented_row() else {
7882            self.home.status =
7883                Some("Ctrl+E shows what a catalog or a format spec says of a row".into());
7884            return;
7885        };
7886        let measured = self
7887            .home
7888            .selected_entry()
7889            .filter(|e| {
7890                e.path == path
7891                    && doc
7892                        .catalog
7893                        .as_ref()
7894                        .is_some_and(|(_, entry)| entry.location() == path)
7895            })
7896            .and_then(|e| e.size);
7897        self.documentation.open(doc, measured);
7898        self.documentation.links_open = self.local_desktop;
7899    }
7900
7901    /// What Ctrl+E documents for the row under the cursor, with the row's path: the
7902    /// catalog dataset it is, or is inside, and what the format spec that reads a file
7903    /// says of it, when the spec documents anything.
7904    pub(crate) fn home_documented_row(
7905        &self,
7906    ) -> Option<(PathBuf, widgets::documentation::Documented)> {
7907        let row = self.home.selected_row();
7908        let (path, file) = match &row {
7909            Some(home::Row::Entry { entry, .. }) | Some(home::Row::Door { entry, .. }) => {
7910                (Some(entry.path.clone()), Some(*entry))
7911            }
7912            Some(home::Row::Place { path, .. }) => (Some(path.clone()), None),
7913            Some(home::Row::Header { section, .. }) => (
7914                self.home
7915                    .sections
7916                    .get(*section)
7917                    .and_then(|s| s.root.clone()),
7918                None,
7919            ),
7920            _ => (None, None),
7921        };
7922        let path = path?;
7923        let catalog = home::catalog_entry_for(&self.home.catalogs, &path);
7924        let spec = file
7925            .filter(|e| e.kind == discover::EntryKind::File)
7926            .and_then(|e| e.format_spec.as_deref())
7927            .and_then(|name| self.home.formats.get(name))
7928            .and_then(|spec| spec.docs())
7929            .map(std::sync::Arc::new);
7930        // A record type's row (`day.ord/add`) documents its file.
7931        let name = file
7932            .map(|e| {
7933                let text = e.path.to_string_lossy();
7934                text.strip_suffix(e.name.as_str())
7935                    .filter(|_| e.table.is_some())
7936                    .map(|file| file.trim_end_matches(std::path::is_separator))
7937                    .and_then(|file| Path::new(file).file_name())
7938                    .map_or_else(|| e.name.clone(), |n| n.to_string_lossy().into_owned())
7939            })
7940            .unwrap_or_default();
7941        let doc = widgets::documentation::Documented::new(catalog, spec, name)?;
7942        Some((path, doc))
7943    }
7944
7945    /// What Ctrl+D does on the row under the cursor, as the footer names it: add it to
7946    /// `catalog.toml`, or forget it from there; `None` on a row it cannot add.
7947    /// Whether Delete on the selected row hides a catalog: the heading of the one
7948    /// that comes with datui.
7949    pub(crate) fn home_hides_catalog(&self) -> bool {
7950        self.home
7951            .selected_catalog()
7952            .is_some_and(|c| c.origin == crate::catalog::Origin::Bundled)
7953    }
7954
7955    pub(crate) fn home_catalog_action(&self) -> Option<&'static str> {
7956        let (location, _) = self.home_row_for_catalog()?;
7957        Some(if self.mine_entry_at(&location).is_some() {
7958            "Forget"
7959        } else {
7960            "Add"
7961        })
7962    }
7963
7964    /// A key while the Documentation view is open over home.
7965    fn documentation_key(&mut self, event: &KeyEvent) {
7966        let page = self.documentation.view_height.max(1) as isize;
7967        match event.code {
7968            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Left => self.documentation.close(),
7969            KeyCode::Up | KeyCode::Char('k') => self.documentation.move_cursor(-1),
7970            KeyCode::Down | KeyCode::Char('j') => self.documentation.move_cursor(1),
7971            KeyCode::PageUp => self.documentation.move_cursor(-page),
7972            KeyCode::PageDown => self.documentation.move_cursor(page),
7973            KeyCode::Home | KeyCode::Char('g') => self.documentation.move_cursor(isize::MIN / 2),
7974            KeyCode::End | KeyCode::Char('G') => self.documentation.move_cursor(isize::MAX / 2),
7975            KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Right => {
7976                self.documentation.toggle_legend();
7977            }
7978            KeyCode::Char('y') => self.copy_documentation_line(),
7979            KeyCode::Char('o') => {
7980                let link = self.documentation.link();
7981                self.ask_to_open_link(link);
7982            }
7983            // The view takes no text, so ? is help here, as at the table.
7984            KeyCode::Char('?') => self.open_help_overlay(),
7985            _ => {}
7986        }
7987    }
7988
7989    /// `y` in the Documentation view: the line's link or value, whole.
7990    fn copy_documentation_line(&mut self) {
7991        match self.documentation.copy_text() {
7992            Some(text) => self.copy_documentation_text(text),
7993            None => self.flash_note("Nothing to copy on this line".to_string()),
7994        }
7995    }
7996
7997    /// `o` on a Documentation page: ask, with the whole URL, before the browser
7998    /// opens it. Nothing on a line without a link; a status line where no local
7999    /// browser would show it, or the link is not http or https.
8000    pub(crate) fn ask_to_open_link(&mut self, link: Option<String>) {
8001        let Some(link) = link else {
8002            return;
8003        };
8004        if !self.local_desktop {
8005            self.flash_note("o opens links on a local desktop; y copies it".to_string());
8006            return;
8007        }
8008        match link_open::checked_url(&link) {
8009            Ok(url) => {
8010                self.confirmation_modal
8011                    .show_choice(format!("Open {url}?"), "Open", "Cancel");
8012                self.pending_link = Some(url);
8013            }
8014            Err(why) => self.flash_note(format!("Not opened: {why}; y copies it")),
8015        }
8016    }
8017
8018    /// Put a line of a Documentation page on the clipboard, whole.
8019    pub(crate) fn copy_documentation_text(&mut self, text: String) {
8020        let shown: String = text.chars().take(60).collect();
8021        let said = if shown.len() < text.len() {
8022            format!("Copied {shown}{}", glyphs::get().ellipsis)
8023        } else {
8024            format!("Copied {shown}")
8025        };
8026        self.finish_copy(clipboard::Payload::text(text), said);
8027    }
8028
8029    /// Collapse or expand the section the cursor is in.
8030    ///
8031    /// Collapsing moves the cursor to the header, so the section the user just folded
8032    /// is what stays selected rather than whatever row happens to fall into place.
8033    /// Fold or unfold the section whose header is highlighted.
8034    fn home_toggle_fold(&mut self) {
8035        if let Some(section) = self.home.selected_section() {
8036            self.home.toggle_collapsed(section);
8037            self.home.clamp_selection();
8038            self.cache.save_folds(&self.home.folds);
8039        }
8040    }
8041
8042    fn home_collapse(&mut self, collapse: bool) {
8043        // The listing browsed into is the whole screen. It never folds, and the fold
8044        // must not be remembered for its path either — see `set_collapsed`.
8045        if self.home.browsing.is_some() {
8046            return;
8047        }
8048        let Some(section) = self.home.selected_section() else {
8049            return;
8050        };
8051        if collapse && !self.home.is_collapsed(section) {
8052            self.home.set_collapsed(section, true);
8053            if let Some(idx) = self
8054                .home
8055                .visible()
8056                .iter()
8057                .position(|row| row.section() == section)
8058            {
8059                self.home.selected = idx;
8060            }
8061        } else if !collapse {
8062            self.home.set_collapsed(section, false);
8063        }
8064        self.home.clamp_selection();
8065        self.cache.save_folds(&self.home.folds);
8066    }
8067
8068    /// Step out of a directory that was descended into.
8069    fn home_ascend(&mut self) {
8070        let Some(current) = self.home.browsing.clone() else {
8071            return;
8072        };
8073        let parent = self.home.parent_of(&current);
8074        self.home_leave_browsing(parent);
8075    }
8076
8077    /// Move the browse up to `to`, or back to the root listing when `None`.
8078    fn home_leave_browsing(&mut self, to: Option<PathBuf>) {
8079        // Whatever the last place said about itself, it said about that place. "these
8080        // are the files under it" is wrong the moment "it" is somewhere else.
8081        self.home.status = None;
8082        let from = std::mem::replace(&mut self.home.browsing, to);
8083        // Backspace can climb above where the browse began; the start follows, so a
8084        // later Esc still has a place to stop.
8085        if !self.home.below_browse_start() {
8086            self.home.browse_start = self.home.browsing.clone();
8087        }
8088        // The filter, search and row the user left here, if they were here; the
8089        // listing lands later, and the cursor goes back to that row when it does.
8090        // Somewhere new, a search of the old place no longer answers the question.
8091        self.home.come_back(from);
8092        self.home.sync_search_section();
8093        self.home.selected = 0;
8094        self.home_refresh();
8095        if !self.home.filter.is_empty() {
8096            self.spawn_home_search();
8097        }
8098    }
8099
8100    /// Whether a peek's answer changes anything a row draws.
8101    ///
8102    /// Every answer that tells a row something, not only the ones that change the kind:
8103    /// a prefix of twelve CSV objects is a `Directory` — only Parquet is read in place —
8104    /// and it is still `12 csv`, which is the count the row is labelled from.
8105    ///
8106    /// An answer that says neither is replaced by "a directory, and nothing to say
8107    /// about it" rather than dropped. The directory still has to come back — that is what
8108    /// takes it out of `peeking` and holds the one-request-per-directory promise — and
8109    /// once the request has been made, "nothing to say" is a real answer rather than
8110    /// the claim it was when it was being written before the request. What this decides
8111    /// is whether the peek's own words are kept.
8112    ///
8113    /// "Says something" is `Holds::is_empty`, not the formats alone. The row is not the
8114    /// only thing an answer reaches: the details pane draws the whole `holds` line, so a
8115    /// prefix of a README and two PDFs has `3 not read` to report, and one of twelve
8116    /// sub-prefixes has `12 directories`. Testing the formats dropped both, and the same
8117    /// directories on disk said both things.
8118    ///
8119    /// The cost is real and is the reason the distinction is kept: each batch rebuilds
8120    /// the listing on the thread drawing the frame. `Holds::is_empty` is the line
8121    /// because it is the same question the pane asks before drawing the line at all.
8122    ///
8123    /// A `Holds` whose only field is `truncated` is let through too: it is what turns
8124    /// a cloud row's `dir` into `dir+`.
8125    #[cfg(feature = "cloud")]
8126    fn peek_tells_a_row_something(answer: &(discover::EntryKind, discover::Holds)) -> bool {
8127        answer.0 != discover::EntryKind::Directory || !answer.1.is_empty()
8128    }
8129
8130    /// Look inside the cloud directories the cursor is on or near, so the ones that are
8131    /// datasets say `hive` or `multi` and open as one. One small listing request per
8132    /// directory, and each directory is peeked at once per session.
8133    ///
8134    /// A directory the listing takes for `multi` costs a little more: up to three ranged
8135    /// reads of a few kilobytes each, to ask the footers whether its files are really
8136    /// one table. Nothing else reads an object, and nothing reads a whole one.
8137    ///
8138    /// Driven by the cursor rather than by the listing. It used to take the first
8139    /// forty-eight directories of each listing, once: a bucket of two hundred prefixes
8140    /// had forty-eight labelled and the rest reading `dir` for the session however long
8141    /// you spent on them, and paging straight past those forty-eight spent the requests
8142    /// on rows nobody saw. The budget is the same shape as the local classify pass now —
8143    /// what is on screen, a batch at a time, the highlighted row first.
8144    #[cfg(feature = "cloud")]
8145    fn peek_cloud_directories(&mut self) {
8146        const PEEKS_AT_ONCE: usize = 4;
8147        let directories = self.home.cloud_directories_to_peek(PEEKS_AT_ONCE);
8148        if directories.is_empty() {
8149            return;
8150        }
8151        // Out, not answered. A second pass before these land must not ask again, and an
8152        // answer written here instead would be a claim — `dir` on a row that has a
8153        // count, and "never again this session" staked on a request that may fail.
8154        for directory in &directories {
8155            self.home.peeking.insert(directory.clone());
8156        }
8157        let tx = self.events.clone();
8158        let cloud = self.app_config.cloud.clone();
8159        self.runtime.spawn(async move {
8160            let permits = Arc::new(tokio::sync::Semaphore::new(PEEKS_AT_ONCE));
8161            let mut peeks = tokio::task::JoinSet::new();
8162            // By task, so a peek that panics is still sent back, as failed, and does
8163            // not stay in `peeking` spinning for good.
8164            let mut asked = std::collections::HashMap::new();
8165            for directory in directories {
8166                let (permits, cloud) = (permits.clone(), cloud.clone());
8167                let task_directory = directory.clone();
8168                let task = peeks.spawn(async move {
8169                    let directory = task_directory;
8170                    let _permit = permits.acquire_owned().await;
8171                    let kind =
8172                        crate::cloud_browse::peek_kind(&directory.to_string_lossy(), &cloud).await;
8173                    (directory, kind)
8174                });
8175                asked.insert(task.id(), directory);
8176            }
8177            // Sent a few at a time: the labels fill in as they are found, without a
8178            // rebuild per directory.
8179            //
8180            // Every directory asked about is sent back, including the ones whose peek
8181            // decided nothing and the ones whose request failed. That is what takes
8182            // them out of `peeking` and what holds the one-request-per-directory promise
8183            // — and an answer of "a directory, and nothing to say about it" is a real
8184            // answer once the request has been made, which is what it was not while it
8185            // was being written before the request.
8186            let mut found = Vec::new();
8187            let mut failed = Vec::new();
8188            while let Some(joined) = peeks.join_next_with_id().await {
8189                match joined {
8190                    Ok((_, (directory, Ok(answer)))) => {
8191                        let answer = Some(answer)
8192                            .filter(Self::peek_tells_a_row_something)
8193                            .unwrap_or((discover::EntryKind::Directory, Default::default()));
8194                        found.push((directory, answer));
8195                    }
8196                    Ok((_, (directory, Err(_)))) => failed.push(directory),
8197                    Err(error) => failed.extend(asked.remove(&error.id())),
8198                }
8199                if found.len() + failed.len() >= PEEKS_AT_ONCE {
8200                    let _ = tx.send(AppEvent::HomeCloudKinds {
8201                        kinds: std::mem::take(&mut found),
8202                        failed: std::mem::take(&mut failed),
8203                    });
8204                }
8205            }
8206            if !found.is_empty() || !failed.is_empty() {
8207                let _ = tx.send(AppEvent::HomeCloudKinds {
8208                    kinds: found,
8209                    failed,
8210                });
8211            }
8212        });
8213    }
8214
8215    /// Browse into a directory or bucket, local or remote.
8216    fn home_browse_into(&mut self, path: PathBuf) {
8217        self.home.leave_mark();
8218        if self.home.browsing.is_none() {
8219            self.home.browse_start = Some(path.clone());
8220        } else if self.home.browse_start.is_none() {
8221            self.home.browse_start = self.home.browsing.clone();
8222        }
8223        self.home.browsing = Some(path);
8224        // "Below here" now means somewhere else. Whatever the last walk found
8225        // describes a different place, and a fresh one starts on the next
8226        // keystroke. The status line goes with them: "these are the files under it"
8227        // is about wherever "it" was. A caller with something to say about the place
8228        // it is going says it after this returns.
8229        self.home.status = None;
8230        self.home.search.reset();
8231        self.home.filter.clear();
8232        self.home.sync_search_section();
8233        self.home.selected = 0;
8234        self.home_refresh();
8235    }
8236
8237    /// Whether the highlighted row is the `(all files)` row: the one that opens the
8238    /// directory being browsed, and so is already inside it.
8239    ///
8240    /// `Enter` on it opens the directory whatever the label says, and → on it would
8241    /// descend into where it already is.
8242    /// Asked of the row's variant rather than of a flag on the entry it carries: the
8243    /// door is a `Row::Door` now, so this is one match instead of a clone.
8244    fn selection_opens_the_whole_directory(&self) -> bool {
8245        self.home.selection_is_the_door()
8246    }
8247
8248    /// The highlighted row, when → goes inside it.
8249    ///
8250    /// Every directory, whatever its label. A label describes what is directly inside; it
8251    /// no longer decides what can be reached, so the exception list this used to carry —
8252    /// hive, multi and the three lake markers — is gone, and with it the directories that
8253    /// had no way in because datui did not recognize how they were stored. What is left
8254    /// out is what is not a directory: a file, a section header, and the row that opens
8255    /// the directory you are already in.
8256    ///
8257    /// Local or remote. The split this used to carry — remote only — was never about
8258    /// where the directory was: a cloud prefix simply could not be descended into until
8259    /// there was a listing to descend with.
8260    fn selected_directory_to_enter(&self) -> Option<PathBuf> {
8261        // A place under `RECENT` is a directory to go inside, and → is one of its two
8262        // doors. It has no entry to ask about, so it is answered before one is looked for.
8263        if let Some(home::Row::Place { path, .. }) = self.home.selected_row() {
8264            return home::place_is_browsable(&path).then_some(path);
8265        }
8266        let entry = self.home.selected_entry()?;
8267        if self.selection_opens_the_whole_directory() || self.home.missing.contains(&entry.path) {
8268            return None;
8269        }
8270        // A SQLite database lists its tables, however many it has.
8271        if entry.cost.tables.is_some() {
8272            return Some(entry.path);
8273        }
8274        (!matches!(
8275            entry.kind,
8276            discover::EntryKind::File | discover::EntryKind::Other
8277        ))
8278        .then_some(entry.path)
8279    }
8280
8281    /// Why a prefix in an object store cannot be read as one table, when it cannot.
8282    ///
8283    /// Every cloud path is scanned as Parquet — the directory-format dispatch is local
8284    /// only — so a prefix of anything else comes back "Could not read from S3. Check
8285    /// credentials and URL", which is a false statement about a login that is fine.
8286    /// What the prefix holds is already counted and on screen, so saying so costs no
8287    /// request. #275 phase 4 is where these read.
8288    ///
8289    /// `None` for a prefix that may yet be Parquet: one holding Parquet, and one
8290    /// holding no data files at all, whose data may be a level down.
8291    /// Why Enter on a bucket directory's `(all files)` row reads nothing, by the rule
8292    /// Enter itself applies: a hive root or a directory of one table is read through
8293    /// its files, and one with a reader for what it holds is read with that.
8294    #[cfg(feature = "cloud")]
8295    pub(crate) fn why_a_door_reads_nothing(entry: &discover::Entry) -> Option<String> {
8296        if !home::is_object_store_url(&entry.path)
8297            || matches!(
8298                entry.kind,
8299                discover::EntryKind::Hive | discover::EntryKind::MultiFile
8300            )
8301            || Self::cloud_prefix_format(&entry.holds).is_some()
8302        {
8303            return None;
8304        }
8305        Self::why_a_cloud_prefix_cannot_be_read(&entry.holds)
8306    }
8307
8308    #[cfg(feature = "cloud")]
8309    fn why_a_cloud_prefix_cannot_be_read(holds: &discover::Holds) -> Option<String> {
8310        let reads_parquet =
8311            |name: &str| crate::FileFormat::from_name(name) == Some(crate::FileFormat::Parquet);
8312        if holds.formats.iter().any(|(name, _)| reads_parquet(name)) {
8313            return None;
8314        }
8315        match holds.formats.as_slice() {
8316            // Data files, none of them Parquet. `label()` says `mixed` for more than
8317            // one format, which is a word rather than a count, so the line is spelled
8318            // out from the formats themselves.
8319            [] => {
8320                // Nothing datui has a reader for. Only a refusal when there is also
8321                // nothing below: a prefix of sub-prefixes may hold Parquet a level
8322                // down, and nothing here has looked.
8323                (holds.not_read > 0 && holds.directories == 0).then(|| {
8324                    "this prefix holds nothing datui can read — datui reads a directory in \
8325                     an object store as Parquet only."
8326                        .to_string()
8327                })
8328            }
8329            formats => {
8330                let held = formats
8331                    .iter()
8332                    .map(|(name, count)| format!("{count} {name}"))
8333                    .collect::<Vec<_>>()
8334                    .join(", ");
8335                Some(format!(
8336                    "this prefix holds {held} — datui reads a directory in an object store \
8337                     as Parquet only. Open one of the files below instead."
8338                ))
8339            }
8340        }
8341    }
8342
8343    /// The reader a prefix in an object store calls for, from what its listing counted.
8344    ///
8345    /// The commonest format, which is the same rule a directory on disk follows — and
8346    /// `rank_formats` is the same order, so a prefix and the directory it mirrors pick
8347    /// the same reader. `None` when nothing there has a multi-file reader, which is where
8348    /// the refusal that names what is there belongs.
8349    ///
8350    /// Parquet included and returned as itself: the cloud branches compare against it
8351    /// and take their own path, which is the one every cloud dataset took before any of
8352    /// this, and the only one with hive partitioning behind it.
8353    #[cfg(feature = "cloud")]
8354    fn cloud_prefix_format(
8355        holds: &discover::Holds,
8356    ) -> Option<(FileFormat, Vec<(FileFormat, usize)>)> {
8357        // A saved DatasetDict: its splits are Arrow, read one at a time.
8358        if holds.dataset_dict {
8359            return Some((FileFormat::Arrow, Vec::new()));
8360        }
8361        // A model's weights beside its config and tokenizer JSON: the prefix is the
8362        // model, as a directory on disk is, and the JSON is not data passed over.
8363        if let Some((name, _)) = holds.model_weights() {
8364            return FileFormat::from_name(name).map(|format| (format, Vec::new()));
8365        }
8366        let (name, _) = holds.formats.first()?;
8367        // A GPS log is read whole from disk; a bucket's logs are opened one at a time,
8368        // as its text files are.
8369        let format = FileFormat::from_name(name)
8370            .filter(|f| f.reads_many_files() && !f.reads_into() && !f.is_lines())?;
8371        // And what taking the commonest passes over. The local read reports its own —
8372        // it is the pass that decides — but here Polars does the listing and never sees
8373        // the other formats, so the note has to be written from the listing on screen.
8374        let left_out = holds
8375            .formats
8376            .iter()
8377            .skip(1)
8378            .filter_map(|(name, n)| FileFormat::from_name(name).map(|f| (f, *n)))
8379            .collect();
8380        Some((format, left_out))
8381    }
8382
8383    /// Open the highlighted entry: toggle a section, descend into a directory, or
8384    /// load a dataset.
8385    fn home_open_selected(&mut self) -> Option<AppEvent> {
8386        match self.home.selected_row() {
8387            // Into the directory or prefix the recents under it live in: the way back
8388            // to a place found by hand, now that recents no longer make roots.
8389            Some(home::Row::Place { path, .. }) => {
8390                if home::place_is_browsable(&path) {
8391                    self.home_browse_into(path);
8392                } else {
8393                    self.home.status = Some(
8394                        "An HTTP server has no listing to browse. Open a file under it".into(),
8395                    );
8396                }
8397                return None;
8398            }
8399            // The rest of `RECENT`, for the session.
8400            Some(home::Row::More { .. }) => {
8401                self.home.recent_expanded = true;
8402                return None;
8403            }
8404            // What Ctrl+A shows. The cursor goes to the first of them, where the row
8405            // that stood for them was.
8406            Some(home::Row::Hidden { .. }) => {
8407                self.home.hide_unreadable = false;
8408                if let Some(idx) = self.home.visible().iter().position(|row| {
8409                    matches!(row, home::Row::Entry { entry, .. }
8410                        if entry.hidden_by_default())
8411                }) {
8412                    self.home.selected = idx;
8413                }
8414                return None;
8415            }
8416            _ => {}
8417        }
8418        if self.home.selection_is_header() {
8419            self.home_toggle_fold();
8420            return None;
8421        }
8422        let entry = self.home.selected_entry()?;
8423        // A collection's local dataset that is not there: said here, where it was named.
8424        if self.home.missing.contains(&entry.path) {
8425            self.home.status = Some(format!(
8426                "{} does not exist",
8427                home::display_path(&entry.path)
8428            ));
8429            return None;
8430        }
8431        // A place a collection suggests is a starting point: Enter opens it as one
8432        // table rather than stepping inside. → still goes in.
8433        if entry.kind != discover::EntryKind::File && self.home.bookmark(&entry.path).is_some() {
8434            #[cfg(feature = "cloud")]
8435            let reader = if home::is_object_store_url(&entry.path)
8436                && !matches!(
8437                    entry.kind,
8438                    discover::EntryKind::Hive | discover::EntryKind::MultiFile
8439                ) {
8440                Self::cloud_prefix_format(&entry.holds)
8441            } else {
8442                None
8443            };
8444            #[cfg(not(feature = "cloud"))]
8445            let reader = None;
8446            let directory = home::directory_dataset_url(&entry.path);
8447            return Some(self.home_open_directory_as(directory, true, None, reader));
8448        }
8449        // The `(all files)` row opens the directory it names, whatever the directory is
8450        // labelled. That is the whole of what it is for: the label describes, and this
8451        // row is the promise that the description cannot lock you out. Sent straight to
8452        // the open, because `open_what_it_is` would read the label back and send a
8453        // `dir` row inside the directory it is already in.
8454        if self.selection_opens_the_whole_directory() {
8455            // A lake table is not a directory of Parquet files however much it looks like
8456            // one: reading it as one counts tombstoned rows, every rewritten version
8457            // and both sides of a compaction. So the read is labelled rather than
8458            // refused. Refusing it left a directory the user could see and could not read
8459            // at all — this row is the promise that no label locks you out, and a
8460            // refusal here is that promise broken on the one directory that needed it.
8461            // Until datui reads the log, its files are what there is, and what makes
8462            // that honest is that nothing about it is silent: a note in the panel, a
8463            // chip in the control bar, and `Enter` on the row one level up still goes
8464            // inside and says datui does not read the table itself yet.
8465            let lake = entry.kind.lake_name();
8466            // A prefix in an object store used to be scanned as Parquet whatever was
8467            // in it — every cloud path returns before the directory-format dispatch is
8468            // reached — so a prefix of CSV answered "Could not read from S3. Check
8469            // credentials and URL", a false statement about the user's login. What the
8470            // prefix holds was counted by the listing and is on screen, so the reader
8471            // is picked from it, which costs no request. Only a prefix the listing
8472            // already calls a dataset is left alone: a hive root is read through its
8473            // partitions, and one stray `manifest.csv` beside them is not what it
8474            // holds — but it is the only thing in `formats`.
8475            #[cfg(feature = "cloud")]
8476            let reader = if home::is_object_store_url(&entry.path)
8477                && !matches!(
8478                    entry.kind,
8479                    discover::EntryKind::Hive | discover::EntryKind::MultiFile
8480                ) {
8481                let reader = Self::cloud_prefix_format(&entry.holds);
8482                // Nothing here datui has a reader for. The listing is on screen, so the
8483                // refusal names what is there rather than blaming the connection.
8484                if reader.is_none()
8485                    && let Some(what) = Self::why_a_cloud_prefix_cannot_be_read(&entry.holds)
8486                {
8487                    self.home.status = Some(what);
8488                    return None;
8489                }
8490                reader
8491            } else {
8492                None
8493            };
8494            #[cfg(not(feature = "cloud"))]
8495            let reader = None;
8496            // `hive: true` says read this as one, which is the whole of what the row
8497            // promises — it is also what carries partition columns through, for a
8498            // directory the dispatch sends down the hive route. The cloud route returns
8499            // before the dispatch is reached.
8500            let directory = home::directory_dataset_url(&entry.path);
8501            return Some(self.home_open_directory_as(directory, true, lake, reader));
8502        }
8503        // A row nothing has looked at is looked at before it is opened, rather than
8504        // opened as whatever it turns out to be. `EntryKind::Unknown` is offered as
8505        // openable, so without this a lake root reached this way is read as one table:
8506        // #237 through the door #249 leaves open.
8507        let mut entry = entry;
8508        if entry.kind == discover::EntryKind::Unknown {
8509            if self.looking_could_block(&entry.path) {
8510                return Some(AppEvent::ClassifyThenOpen {
8511                    path: entry.path,
8512                    jump: false,
8513                });
8514            }
8515            if entry.path.is_dir() {
8516                entry.kind = discover::classify_directory(&entry.path);
8517            }
8518        }
8519        // A database of several tables lists them rather than opening; one not yet
8520        // measured is opened, and the open lands on its tables the same way.
8521        if entry.enter_lists_tables() {
8522            self.home_browse_into(entry.path);
8523            return None;
8524        }
8525        self.open_what_it_is(entry.path, entry.kind, false)
8526    }
8527
8528    /// Whether finding out what a path is could sit on a mount that never answers.
8529    ///
8530    /// Two halves. An object-store or HTTP URL names something no mount is responsible
8531    /// for — what is behind it is the scan's business, and stat'ing it only ever asks the
8532    /// working directory about a file called `s3:` — and an ordinary local path answers at
8533    /// once, so making the user wait a round trip for it would be a delay bought with
8534    /// nothing.
8535    ///
8536    /// What is left is a path on a mount the home screen calls a network one, which is
8537    /// the case `is_remote_path` exists to name and the only one worth a worker.
8538    fn looking_could_block(&self, path: &Path) -> bool {
8539        // `cloud://<id>` is a place, not a path: `input_source` calls the unknown scheme
8540        // local and `is_remote_path` calls it remote, so without this a worker would be
8541        // sent to stat it and come back with "No such path".
8542        !home::is_cloud_place(path)
8543            && matches!(source::input_source(path), source::InputSource::Local(_))
8544            && (self.home.network_check)(path)
8545    }
8546
8547    /// Do with a path whatever its kind calls for: browse into it, say it is a lake
8548    /// table, or open it.
8549    ///
8550    /// `jump` is a path typed at `~` rather than a row already listed, which starts a new
8551    /// browse so Esc comes back from there to the listing.
8552    fn open_what_it_is(
8553        &mut self,
8554        path: PathBuf,
8555        kind: discover::EntryKind,
8556        jump: bool,
8557    ) -> Option<AppEvent> {
8558        let go_inside = |app: &mut Self, path: PathBuf| {
8559            if jump {
8560                app.home_jump_into(path);
8561            } else {
8562                app.home_browse_into(path);
8563            }
8564        };
8565        if kind == discover::EntryKind::Directory {
8566            go_inside(self, path);
8567            return None;
8568        }
8569        // No reader: a local file's bytes, in the hex view. A remote one is dimmed and
8570        // its details pane says why.
8571        if kind == discover::EntryKind::Other {
8572            if matches!(source::input_source(&path), source::InputSource::Local(_)) {
8573                self.open_hex(path, crate::hex_view::Origin::Home, true, None);
8574            }
8575            return None;
8576        }
8577        // A lake table's files are not its rows: the ones a delete or an update
8578        // tombstoned are still on disk, every rewritten version is here together, and
8579        // compaction leaves both sides in place. Going inside is what datui can honestly
8580        // do with one, and saying so is better than a silent wrong answer.
8581        if let Some(format) = kind.lake_name() {
8582            self.home.lake_here = Some((path.clone(), format));
8583            go_inside(self, path);
8584            return None;
8585        }
8586        let directory = matches!(
8587            kind,
8588            discover::EntryKind::Hive | discover::EntryKind::MultiFile
8589        );
8590        // A directory typed at `~` is a place to go, as → makes it, whatever it holds:
8591        // its door is one row in, and naming a directory never starts a read of all of it.
8592        if directory && jump {
8593            go_inside(self, path);
8594            return None;
8595        }
8596        // A cloud directory that is a dataset opens as one: its URL as a prefix, which is
8597        // what makes the open a scan of every file under it.
8598        if directory && home::is_object_store_url(&path) {
8599            // A prefix, not a directory: the scan is what walks it.
8600            return Some(self.home_open_path(home::directory_dataset_url(&path), false));
8601        }
8602        // Said here, where the file was named, rather than after a download and a load
8603        // that could only end the same way. A row would be dimmed; a typed path has no
8604        // row, so the line says it.
8605        // A format spec may read it: by its glob, or by magic the open looks for.
8606        let a_spec_may_read = !self.formats.by_glob(&path, false).is_empty()
8607            || self.formats.specs.iter().any(|f| !f.spec.magic.is_empty());
8608        // A table inside a file of tables (`flight.ulg/sensor_accel.1`) has the file's
8609        // name in front, and a log found by its first bytes (`00000042.BIN`) a name
8610        // that says nothing.
8611        if kind == discover::EntryKind::File
8612            && discover::unreadable_by_name(&path)
8613            && !a_spec_may_read
8614            && crate::members::split(&path).is_none()
8615            && crate::members::holder(&path).is_none()
8616            && crate::members::split_variant(&path, &self.formats).is_none()
8617            && crate::hf_splits::split_place(&path).is_none()
8618        {
8619            self.home.status = Some(discover::NO_READER.to_string());
8620            return None;
8621        }
8622        // The preview read this file's first page through the open's own steps: the
8623        // open installs that dataset rather than reading it again.
8624        let prepared = (!directory)
8625            .then(|| self.home_previews.take_prepared(&path))
8626            .flatten();
8627        // A small file of the built-in catalog is fetched without a question: the row
8628        // already said what it is and what it weighs. A URL the user typed still asks.
8629        let unasked = self
8630            .home
8631            .catalogs
8632            .iter()
8633            .filter(|c| c.origin == catalog::Origin::Bundled)
8634            .flat_map(|c| c.datasets.iter())
8635            .find(|d| d.location == path)
8636            .filter(|_| {
8637                !jump && matches!(source::input_source(&path), source::InputSource::Http(_))
8638            })
8639            .map(|dataset| UnaskedDownload {
8640                limit: UnaskedDownload::LIMIT,
8641                listed: dataset.size,
8642            });
8643        match self.home_open_path(path, directory) {
8644            AppEvent::Open(paths, mut options) => {
8645                options.prepared = prepared.map(|p| Arc::new(Mutex::new(Some(p))));
8646                options.download_unasked = unasked;
8647                Some(AppEvent::Open(paths, options))
8648            }
8649            event => Some(event),
8650        }
8651    }
8652
8653    /// What `datui <path>` does with a directory: the same rule as `Enter` on its row.
8654    ///
8655    /// A directory used to be `Unsupported file type` unless `--hive` was passed, while
8656    /// pyarrow, Polars, pandas and Spark all open one. Naming a directory *is* the
8657    /// request to read it, so the three doors onto a path — the highlighted row, the `~`
8658    /// prompt and the command line — now answer the same: a hive root or a directory
8659    /// whose files are one table opens as one table, and a directory that is a place to
8660    /// look inside opens the home screen browsed into it, one keystroke from either file
8661    /// or union.
8662    ///
8663    /// The directory is looked into rather than guessed at, because that is what the
8664    /// rule is: [`home::look_into`] is the same call the home screen's background pass
8665    /// makes, footers and all.
8666    ///
8667    /// `--hive` is untouched. It names a glob or forces partition columns, and it is
8668    /// still the only way to say "read this as partitioned" about something whose
8669    /// layout does not say so itself.
8670    ///
8671    /// Asks the filesystem whether a local path is a directory, so `run` calls it on a
8672    /// worker ([`AppEvent::OpenNamed`]). Returns the event that carries the open on:
8673    /// `LookThenOpenDirectory` or `Open`.
8674    pub fn route_named_paths(paths: Vec<PathBuf>, options: OpenOptions) -> AppEvent {
8675        Self::route_named_paths_with(paths, options, &crate::formats::Registry::default())
8676    }
8677
8678    /// [`Self::route_named_paths`], with the format specs on the search path: a
8679    /// directory a spec reads as column files is opened, not looked at.
8680    pub fn route_named_paths_with(
8681        paths: Vec<PathBuf>,
8682        options: OpenOptions,
8683        formats: &crate::formats::Registry,
8684    ) -> AppEvent {
8685        if let Some(event) = Self::route_named_without_looking(&paths, &options) {
8686            return event;
8687        }
8688        // Several paths are a list of files to read together, and `--hive` is an answer
8689        // already given. Neither is a question about what one directory is.
8690        let single = (paths.len() == 1 && !options.hive).then(|| paths[0].clone());
8691        let Some(dir) = single.filter(|p| p.is_dir()) else {
8692            return AppEvent::Open(paths, options);
8693        };
8694        // A format spec named for it, or one whose glob names it, reads it as columns.
8695        if options.spec_file.is_some()
8696            || options.spec_name.is_some()
8697            || !formats.by_glob(&dir, true).is_empty()
8698        {
8699            return AppEvent::Open(paths, options);
8700        }
8701        // Looking at a directory reads its footers, or the front of a spread of its
8702        // files. For a directory of large Parquet that is seconds — 4.6 of them on a real
8703        // one — so it goes to a worker, and the answer comes back as an event like every
8704        // other read.
8705        AppEvent::LookThenOpenDirectory(dir, options)
8706    }
8707
8708    /// The part of [`Self::route_named_paths`] that needs no filesystem: a cloud
8709    /// directory is looked at too, by one page of its listing — what is in it picks the
8710    /// reader, as it does for the `(all files)` row. Scanned blind, it was read as
8711    /// Parquet whatever it held. A glob, a file name or `--format` already says what to
8712    /// read.
8713    fn route_named_without_looking(paths: &[PathBuf], options: &OpenOptions) -> Option<AppEvent> {
8714        #[cfg(feature = "cloud")]
8715        if let [dir] = paths
8716            && !options.hive
8717            && home::is_object_store_url(dir)
8718            && options.format.is_none()
8719            && !dir.to_string_lossy().contains('*')
8720            && !home::names_a_file(dir)
8721        {
8722            return Some(AppEvent::LookThenOpenDirectory(
8723                dir.clone(),
8724                options.clone(),
8725            ));
8726        }
8727        let _ = (paths, options);
8728        None
8729    }
8730
8731    /// The first named local path that is not there. A URL or a glob is left to the
8732    /// open, which says what it found, and standard input is no path.
8733    pub fn missing_named_path(
8734        paths: &[PathBuf],
8735        formats: &crate::formats::Registry,
8736    ) -> Option<PathBuf> {
8737        paths
8738            .iter()
8739            .find(|path| {
8740                !source::is_remote_url(path)
8741                    && !crate::stdin::is_stdin(path)
8742                    && !source::expands_as_glob(path)
8743                    && !path.exists()
8744                    && crate::members::split(path).is_none()
8745                    && crate::members::split_variant(path, formats).is_none()
8746            })
8747            .cloned()
8748    }
8749
8750    /// Act on what the look at a directory named on the command line found.
8751    ///
8752    /// The other half of [`Self::route_named_paths`], which is
8753    /// where the reasoning for the rule itself is.
8754    fn open_the_directory_looked_at(
8755        &mut self,
8756        dir: PathBuf,
8757        kind: discover::EntryKind,
8758        holds: Option<&discover::Holds>,
8759        mut options: OpenOptions,
8760    ) -> Option<AppEvent> {
8761        #[cfg(feature = "cloud")]
8762        if home::is_object_store_url(&dir) {
8763            return self.open_the_cloud_directory_looked_at(dir, kind, holds, options);
8764        }
8765        let _ = holds;
8766        // No override for the user's reader settings here, and none needed: the look
8767        // read every file the way this open will, so `--no-header` and the skips have
8768        // already been accounted for by the rule rather than around it. Overriding
8769        // instead took three goes to get wrong in three different ways — it fired on
8770        // config values, it fired on directories with nothing readable in them, and it
8771        // fired on Parquet, which no CSV setting can affect.
8772        //
8773        // A lake table's files are not its rows, so the home screen is opened on it and
8774        // says why — the same sentence the row gives, because it is the same refusal.
8775        if let Some(format) = kind.lake_name() {
8776            self.enter_home();
8777            self.home.lake_here = Some((dir.clone(), format));
8778            self.home_jump_into(dir);
8779            return None;
8780        }
8781        // One table: read it. `hive` is what puts the open on the directory route, where
8782        // what the directory holds picks the reader.
8783        if matches!(
8784            kind,
8785            discover::EntryKind::Hive | discover::EntryKind::MultiFile
8786        ) {
8787            options.hive = true;
8788            self.set_loading_phase("Scanning input", 10);
8789            self.name_what_is_loading(dir.clone());
8790            return Some(AppEvent::Open(vec![dir], options));
8791        }
8792        // A place to look inside. `datui .` is this, and so is a directory of separate
8793        // tables — where the `(all files)` row inside is the one keystroke that unions
8794        // them anyway.
8795        self.enter_home();
8796        self.home_jump_into(dir);
8797        None
8798    }
8799
8800    /// As [`Self::open_the_directory_looked_at`], for a cloud directory: what `Enter`
8801    /// on its `(all files)` row does, or a browse into it when there is no data
8802    /// directly inside to read.
8803    #[cfg(feature = "cloud")]
8804    fn open_the_cloud_directory_looked_at(
8805        &mut self,
8806        dir: PathBuf,
8807        kind: discover::EntryKind,
8808        holds: Option<&discover::Holds>,
8809        options: OpenOptions,
8810    ) -> Option<AppEvent> {
8811        let open = |app: &mut Self, path: PathBuf, options: OpenOptions| {
8812            app.set_loading_phase("Scanning input", 10);
8813            app.name_what_is_loading(path.clone());
8814            Some(AppEvent::Open(vec![path], options))
8815        };
8816        // The listing was refused. The open says why, in the words of whatever
8817        // refused it, which is what happened before anything looked.
8818        let Some(holds) = holds else {
8819            return open(self, dir, options);
8820        };
8821        if let Some(format) = kind.lake_name() {
8822            self.enter_home();
8823            self.home.lake_here = Some((dir.clone(), format));
8824            self.home_jump_into(dir);
8825            return None;
8826        }
8827        let directory = home::directory_dataset_url(&dir);
8828        if matches!(
8829            kind,
8830            discover::EntryKind::Hive | discover::EntryKind::MultiFile
8831        ) {
8832            let options = OpenOptions {
8833                hive: true,
8834                ..options
8835            };
8836            return open(self, directory, options);
8837        }
8838        if let Some((format, left_out)) = Self::cloud_prefix_format(holds) {
8839            let options = OpenOptions {
8840                hive: true,
8841                format: Some(format),
8842                left_out,
8843                ..options
8844            };
8845            return open(self, directory, options);
8846        }
8847        // Only directories, or nothing datui reads: somewhere to look inside, with
8848        // the reason when there is one.
8849        self.enter_home();
8850        self.home_jump_into(dir);
8851        self.home.status = Self::why_a_cloud_prefix_cannot_be_read(holds);
8852        None
8853    }
8854
8855    /// Browse into `path` as a jump, from wherever the user was.
8856    ///
8857    /// Unlike `home_browse_into`, the browse *starts* here: Esc comes back from here to
8858    /// the listing rather than up through whatever the path happens to sit under.
8859    fn home_jump_into(&mut self, path: PathBuf) {
8860        // A new browse: Esc comes back from here to the listing, so only the listing's
8861        // mark is still a way back.
8862        self.home.trail.retain(|mark| mark.place.is_none());
8863        if self.home.browsing.is_none() {
8864            self.home.leave_mark();
8865        }
8866        self.home.browse_start = Some(path.clone());
8867        self.home.browsing = Some(path);
8868        self.home.status = None;
8869        self.home.search.reset();
8870        self.home.filter.clear();
8871        self.home.sync_search_section();
8872        self.home.selected = 0;
8873        self.home_refresh();
8874    }
8875
8876    /// What an open the home screen starts reads with: the config's read and CSV
8877    /// settings, as an open named on the command line has them under its flags.
8878    fn open_defaults(&self) -> OpenOptions {
8879        match crate::cli::parse_args(["datui"]) {
8880            Ok(args) => OpenOptions::from_args_and_config(&args, &self.app_config),
8881            Err(_) => OpenOptions::default(),
8882        }
8883    }
8884
8885    /// Load a path from the home screen.
8886    ///
8887    /// The recent entry is recorded by the `Open` handler, which every open goes
8888    /// through, so this does not record one itself.
8889    fn home_open_path(&mut self, path: PathBuf, hive: bool) -> AppEvent {
8890        self.home_open_directory(path, hive, None)
8891    }
8892
8893    /// As [`Self::home_open_path`], and carrying whether the directory being read is a
8894    /// lake table whose plain files this read is, so the dataset can say so.
8895    fn home_open_directory(
8896        &mut self,
8897        path: PathBuf,
8898        hive: bool,
8899        lake: Option<&'static str>,
8900    ) -> AppEvent {
8901        self.home_open_directory_as(path, hive, lake, None)
8902    }
8903
8904    /// As [`Self::home_open_directory`], naming the reader to use.
8905    ///
8906    /// For a prefix in an object store, where nothing downstream reads the listing: the
8907    /// cloud branches scan before the directory-format dispatch is reached, so the format
8908    /// the listing counted has to travel with the open or the scan falls back to
8909    /// Parquet, which is what it always did.
8910    fn home_open_directory_as(
8911        &mut self,
8912        path: PathBuf,
8913        hive: bool,
8914        lake: Option<&'static str>,
8915        reader: Option<(FileFormat, Vec<(FileFormat, usize)>)>,
8916    ) -> AppEvent {
8917        let (format, left_out) = match reader {
8918            Some((format, left_out)) => (Some(format), left_out),
8919            None => (None, Vec::new()),
8920        };
8921        // A directory of partitions is only meaningful read as one hive dataset. Told
8922        // rather than stat'ed: the caller already knows what this is, and on a share that
8923        // has gone away a `stat` here would freeze the thread reading the keys — the same
8924        // reason the size below is left to the `Open` handler.
8925        let options = OpenOptions {
8926            hive,
8927            read_as_plain_files_of: lake,
8928            format,
8929            left_out,
8930            ..self.open_defaults()
8931        };
8932        self.input_mode = InputMode::Normal;
8933        // Chosen here, so a failure is reported here.
8934        self.announce_open(true, "Scanning input".to_string(), 10);
8935        // A frame is drawn between this keypress and the `Open` that carries it out,
8936        // and it is the one the user is looking at when they press Enter — so it says
8937        // which file, not just that something is happening. `Open` fills in the size a
8938        // frame later; stat'ing here would put a possibly-dead mount on this thread.
8939        self.name_what_is_loading(path.clone());
8940        AppEvent::Open(vec![path], options)
8941    }
8942
8943    /// Key handling for the home screen.
8944    fn home_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
8945        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
8946        // The line beside the prompt answers the last key, and this one replaces it: a
8947        // key with something to say sets it again below. Left up, "Forgot laps.parquet"
8948        // stayed until the next time the listing changed.
8949        self.home.status = None;
8950
8951        if self.documentation.is_open() {
8952            self.documentation_key(event);
8953            return None;
8954        }
8955
8956        // The home screen puts every plain character into the filter — `q` has to
8957        // type a `q`, or you could never search for "quarterly". Quitting is Ctrl+C,
8958        // handled before this is reached, and Esc once there is no context left to
8959        // back out of.
8960        if self.home.path_input_active {
8961            match event.code {
8962                KeyCode::Esc => {
8963                    self.home.path_input_active = false;
8964                    self.home.path_input.clear();
8965                    self.home.path_listing = None;
8966                    self.home.path_pick = None;
8967                    self.home.status = None;
8968                }
8969                // The list under the prompt is the directory being typed: ↑↓ pick a
8970                // name in it, which Enter and Tab then take.
8971                KeyCode::Up | KeyCode::Down => {
8972                    let n = self.home.path_candidates().len();
8973                    self.home.path_pick = match (event.code, self.home.path_pick) {
8974                        _ if n == 0 => None,
8975                        (KeyCode::Down, None) => Some(0),
8976                        (KeyCode::Down, Some(i)) => Some((i + 1).min(n - 1)),
8977                        (KeyCode::Up, Some(0)) | (KeyCode::Up, None) => None,
8978                        (KeyCode::Up, Some(i)) => Some(i - 1),
8979                        (_, pick) => pick,
8980                    };
8981                }
8982                KeyCode::Enter => {
8983                    if let Some(picked) = self.home.picked_path() {
8984                        self.home.path_input = picked;
8985                        self.home.path_pick = None;
8986                    }
8987                    let raw = self.home.path_input.trim().to_string();
8988                    if raw.is_empty() {
8989                        self.home.path_input_active = false;
8990                        return None;
8991                    }
8992                    let path = home::expand_user_path(&raw);
8993                    // A URL is not stat'ed: `exists` asks the working directory about a
8994                    // file called `gs:`. Its name decides, as it does for a recent — a
8995                    // file opens, and anything else in a bucket is browsed, where the
8996                    // listing says what is there.
8997                    if home::is_object_store_url(&path) || home::is_cloud_place(&path) {
8998                        self.home.path_input.clear();
8999                        self.home.path_input_active = false;
9000                        let kind = if home::names_a_file(&path) {
9001                            discover::EntryKind::File
9002                        } else {
9003                            discover::EntryKind::Directory
9004                        };
9005                        return self.open_what_it_is(path, kind, true);
9006                    }
9007                    // And an HTTP URL is one file.
9008                    if !matches!(source::input_source(&path), source::InputSource::Local(_)) {
9009                        self.home.path_input.clear();
9010                        self.home.path_input_active = false;
9011                        return self.open_what_it_is(path, discover::EntryKind::File, true);
9012                    }
9013                    // Whether it is there, whether it is a directory and what kind of one
9014                    // are three filesystem calls, and a typed path is exactly where a
9015                    // dead mount gets named. All three go to a worker when the mount is
9016                    // one that might not answer.
9017                    if self.looking_could_block(&path) {
9018                        self.home.path_input.clear();
9019                        self.home.path_input_active = false;
9020                        return Some(AppEvent::ClassifyThenOpen { path, jump: true });
9021                    }
9022                    // Before the prompt closes: a typo is worth fixing where it was
9023                    // typed, rather than retyping the whole path.
9024                    if !path.exists()
9025                        && crate::members::split(&path).is_none()
9026                        && crate::members::split_variant(&path, &self.formats).is_none()
9027                    {
9028                        self.home.status = Some(format!("No such path: {}", path.display()));
9029                        return None;
9030                    }
9031                    self.home.path_input.clear();
9032                    self.home.path_input_active = false;
9033                    let kind = if path.is_dir() {
9034                        discover::classify_directory(&path)
9035                    } else {
9036                        discover::EntryKind::File
9037                    };
9038                    return self.open_what_it_is(path, kind, true);
9039                }
9040                KeyCode::Backspace => {
9041                    self.home.path_input.pop();
9042                    self.home.status = None;
9043                }
9044                KeyCode::Char('u') if ctrl => self.home.path_input.clear(),
9045                // What the names listed agree on, as a shell completes, or a name
9046                // picked further down with ↓. Before the listing is in, completion
9047                // reads the directory on a worker.
9048                KeyCode::Tab => {
9049                    let completed = match self.home.path_pick {
9050                        Some(i) if i > 0 => self.home.picked_path(),
9051                        _ => self.home.path_completion(),
9052                    };
9053                    let listed = self
9054                        .home
9055                        .path_listing
9056                        .as_ref()
9057                        .is_some_and(|l| l.dir == home::typed_dir(&self.home.path_input));
9058                    match completed {
9059                        Some(completed) => self.home.path_input = completed,
9060                        None if !listed => self.request_path_completion(),
9061                        None => {}
9062                    }
9063                }
9064                KeyCode::Char(c) if !ctrl => {
9065                    self.home.path_input.push(c);
9066                    self.home.status = None;
9067                }
9068                _ => {}
9069            }
9070            // Whatever changed what is typed puts the pick back on the first name
9071            // that matches, and a new directory is listed.
9072            self.list_the_typed_directory();
9073            if !matches!(event.code, KeyCode::Up | KeyCode::Down) {
9074                self.home.pick_first_path();
9075            }
9076            return None;
9077        }
9078
9079        // A filter kept from before is selected: a character, Backspace or Delete
9080        // replaces it, as a selection in any field; any other key keeps it.
9081        if std::mem::take(&mut self.home.filter_selected) {
9082            let replaces = match event.code {
9083                KeyCode::Char(_) => !ctrl,
9084                KeyCode::Backspace => true,
9085                _ => false,
9086            };
9087            if replaces {
9088                self.home.filter.clear();
9089                self.home.sync_search_section();
9090                self.home.select_first_entry();
9091                if event.code == KeyCode::Backspace {
9092                    return None;
9093                }
9094            }
9095        }
9096
9097        // Every plain character types into the filter, so no letter or bracket is
9098        // a key here: typing "json" must not move the cursor on the "j". Navigation
9099        // is the arrows and the Ctrl chords, which cannot be part of a name.
9100        match event.code {
9101            KeyCode::Esc => return self.home_escape(),
9102            KeyCode::Enter => return self.home_open_selected(),
9103            // Section to section, past however many rows the current one holds.
9104            KeyCode::Down if ctrl => self.home.jump_section(1),
9105            KeyCode::Up if ctrl => self.home.jump_section(-1),
9106            KeyCode::Up => self.home.move_selection(-1),
9107            KeyCode::Down => self.home.move_selection(1),
9108            KeyCode::Char('n') if ctrl => self.home.move_selection(1),
9109            KeyCode::Char('p') if ctrl => self.home.move_selection(-1),
9110            // Left/right fold the section the cursor is in, wherever in it the cursor
9111            // happens to be — so collapsing does not require first finding the header.
9112            // Tab cycles the sort. Every plain key goes into the filter, so an
9113            // ordinary letter is not available for this.
9114            KeyCode::Tab => {
9115                self.home.sort = self.home.sort.next();
9116                self.home.select_first_entry();
9117            }
9118            KeyCode::Left => self.home_collapse(true),
9119            KeyCode::Right => match self.selected_directory_to_enter() {
9120                // Into a directory that opens as one dataset rather than opening it, to
9121                // reach one partition or one file. This clears the filter, as browsing
9122                // anywhere does.
9123                Some(directory) => {
9124                    // The heading Enter leaves, for the same reason: this is the door
9125                    // the control bar advertises on a lake row, and arriving inside one
9126                    // with no explanation is the silent wrong answer #237 is about.
9127                    if let Some(format) = self
9128                        .home
9129                        .selected_entry()
9130                        .and_then(|entry| entry.kind.lake_name())
9131                    {
9132                        self.home.lake_here = Some((directory.clone(), format));
9133                    }
9134                    self.home_browse_into(directory);
9135                }
9136                None => self.home_collapse(false),
9137            },
9138            // A screenful, matching the table; the renderer keeps view_height current.
9139            KeyCode::PageUp => {
9140                let page = self.home.view_height.max(1) as isize;
9141                self.home.page_selection(-page);
9142            }
9143            KeyCode::PageDown => {
9144                let page = self.home.view_height.max(1) as isize;
9145                self.home.page_selection(page);
9146            }
9147            KeyCode::Home => self.home.page_selection(isize::MIN),
9148            KeyCode::End => self.home.page_selection(isize::MAX),
9149            KeyCode::Char('u') if ctrl => {
9150                self.home.filter.clear();
9151                self.home.sync_search_section();
9152                self.home.select_first_entry();
9153                #[cfg(feature = "cloud")]
9154                self.narrow_cloud_listing();
9155            }
9156            KeyCode::Char('r') if ctrl => self.home_reload(),
9157            // A browser's bookmark key: add the row to catalog.toml, or forget it.
9158            KeyCode::Char('d') if ctrl => self.home_toggle_catalog(),
9159            KeyCode::Char('e') if ctrl => self.home_open_documentation(),
9160            // Any local file's bytes, whatever datui would read it as.
9161            KeyCode::Char('x') if ctrl => {
9162                let local = |path: &Path| {
9163                    matches!(source::input_source(path), source::InputSource::Local(_))
9164                };
9165                match self.home.selected_entry() {
9166                    Some(entry)
9167                        if !self.home.selection_is_the_door()
9168                            && entry.table.is_none()
9169                            && matches!(
9170                                entry.kind,
9171                                discover::EntryKind::File
9172                                    | discover::EntryKind::Other
9173                                    | discover::EntryKind::Unknown
9174                            )
9175                            && local(&entry.path) =>
9176                    {
9177                        self.open_hex(entry.path, crate::hex_view::Origin::Home, false, None);
9178                    }
9179                    _ => self.flash_note("Ctrl+X shows a local file's bytes".to_string()),
9180                }
9181            }
9182            KeyCode::Char('a') if ctrl => {
9183                let on = self.home.selected_key();
9184                self.home.hide_unreadable = !self.home.hide_unreadable;
9185                // Inside a database, what is hidden is its own tables.
9186                let tables = self
9187                    .home
9188                    .sections
9189                    .iter()
9190                    .any(|section| section.rows.iter().any(|row| row.table.is_some()));
9191                self.flash_note(
9192                    match (self.home.hide_unreadable, tables) {
9193                        (true, true) => "Hiding internal tables",
9194                        (false, true) => "Showing internal tables",
9195                        (true, false) => "Hiding files with no reader",
9196                        (false, false) => "Showing files with no reader",
9197                    }
9198                    .to_string(),
9199                );
9200                // The same row where it is still there; the cursor stays put otherwise.
9201                self.home.reselect(on);
9202            }
9203            KeyCode::Backspace => {
9204                if self.home.filter.is_empty() {
9205                    self.home_ascend();
9206                } else {
9207                    self.home.filter.pop();
9208                    self.home.sync_search_section();
9209                    self.home.select_first_entry();
9210                    #[cfg(feature = "cloud")]
9211                    self.narrow_cloud_listing();
9212                }
9213            }
9214            // Forget the highlighted entry. Only meaningful in Recent — elsewhere the
9215            // row is a real directory listing, and datui does not delete files.
9216            // Shift+Delete forgets the lot. It sits next to the key that forgets
9217            // one, so it asks first — an accidental press should not silently throw
9218            // away every place the user has been.
9219            KeyCode::Delete if event.modifiers.contains(KeyModifiers::SHIFT) => {
9220                let count = self.cache.load_recents().len();
9221                if count == 0 {
9222                    self.home.status = Some("Nothing to forget".into());
9223                } else {
9224                    self.pending_clear_recents = true;
9225                    self.confirmation_modal
9226                        .show(format!("Forget all {count} recently opened datasets?"));
9227                }
9228            }
9229            KeyCode::Delete => self.home_forget_selected(),
9230            KeyCode::Char('~') if self.home.filter.is_empty() => {
9231                self.home.path_input_active = true;
9232                self.home.status = None;
9233                self.home.path_listing = None;
9234                self.home.path_pick = None;
9235                self.list_the_typed_directory();
9236                self.home.pick_first_path();
9237            }
9238            // The one printable that is a key, and only before typing starts: a
9239            // filter beginning with a literal `?` matches nothing anyway, and this
9240            // is where a new user asks for the keys. F1 opens help mid-filter.
9241            KeyCode::Char('?') if self.home.filter.is_empty() && !ctrl => {
9242                self.open_help_overlay();
9243            }
9244            // Space before typing starts folds a header, as Enter does, and is otherwise
9245            // nothing: a filter of one space is invisible at the prompt and matched every
9246            // name with a space in it, below the working directory too.
9247            KeyCode::Char(' ') if self.home.filter.is_empty() && !ctrl => {
9248                if self.home.selection_is_header() {
9249                    self.home_toggle_fold();
9250                }
9251            }
9252            KeyCode::Char(c) if !ctrl => {
9253                self.home.filter.push(c);
9254                // Typing is what asks for the recursive search. Starting it here and
9255                // not on open means the walk is only ever paid for by someone who is
9256                // actually looking for something.
9257                self.spawn_home_search();
9258                self.home.sync_search_section();
9259                self.home.select_first_entry();
9260                #[cfg(feature = "cloud")]
9261                self.narrow_cloud_listing();
9262            }
9263            _ => {}
9264        }
9265        None
9266    }
9267
9268    /// Get a color from the theme by name
9269    fn color(&self, name: &str) -> Color {
9270        self.theme.get(name)
9271    }
9272
9273    /// The export format to offer by default for a dataset opened from `path`.
9274    ///
9275    /// The format the open read wins (`format`: what it sniffed, or `--format`), then
9276    /// the extension. A compressed CSV keeps its CSV identity: `sales.csv.gz` has
9277    /// extension `gz`, and the `.csv` that matters is in the stem, so reading the
9278    /// extension alone offered no default at all.
9279    fn export_format_for(path: &Path, format: Option<FileFormat>) -> Option<ExportFormat> {
9280        format
9281            .or_else(|| FileFormat::from_path(path))
9282            .or_else(|| {
9283                CompressionFormat::from_extension(path)
9284                    .and(path.file_stem())
9285                    .and_then(|stem| FileFormat::from_path(Path::new(stem)))
9286            })
9287            .and_then(crate::readers::export_default)
9288    }
9289
9290    /// `options` for the compressed delimited file `file`, in the dialect of the
9291    /// delimited spec it matches, or as they are when it matches none. The loader sends
9292    /// such a file straight to be decompressed, past the scan that matches the others.
9293    fn with_delimited_spec(
9294        file: &Path,
9295        mut options: OpenOptions,
9296        formats: &crate::formats::Registry,
9297    ) -> Result<OpenOptions> {
9298        if options.delimited.is_some() {
9299            return Ok(options);
9300        }
9301        let asked = crate::formats::Asked {
9302            spec_file: options.spec_file.clone(),
9303            spec: options.spec_fetched.clone(),
9304            spec_name: options.spec_name.clone(),
9305            compression: options.compression,
9306            ..Default::default()
9307        };
9308        let crate::formats::Route::Delimited(choice) =
9309            crate::formats::route(file, &asked, formats).map_err(|e| color_eyre::eyre::eyre!(e))?
9310        else {
9311            return Ok(options);
9312        };
9313        let Some(delimited) = choice.spec.delimited.clone() else {
9314            return Ok(options);
9315        };
9316        delimited.apply(&mut options);
9317        let chosen =
9318            crate::delimited_spec::DelimitedRead::chosen(choice.spec, choice.by, choice.also);
9319        let read = crate::delimited_spec::read_facts(&chosen, &[file.to_path_buf()], &options)?;
9320        options.delimited = Some(Arc::new(read));
9321        Ok(options)
9322    }
9323
9324    /// Read a compressed CSV, TSV or PSV into a table state, split on its format's
9325    /// separator.
9326    ///
9327    /// This is the one input datui cannot scan lazily: the file has to be
9328    /// decompressed and parsed before anything can be shown, which for a large export
9329    /// is minutes. It takes no `&self` so it can run on a background thread.
9330    fn decompressed_delimited_state(
9331        path: &Path,
9332        options: &OpenOptions,
9333        writer: &crate::unfinished::Writer,
9334    ) -> Result<DataTableState> {
9335        let separator = options
9336            .format
9337            .and_then(FileFormat::separator)
9338            .unwrap_or(b',');
9339        DataTableState::from_delimited_for_open(path, separator, options, writer)
9340    }
9341
9342    /// Polars' view of one source's S3 settings, for `scan_parquet`.
9343    #[cfg(feature = "cloud")]
9344    fn build_s3_cloud_options(settings: &crate::cloud_sources::S3Settings) -> CloudOptions {
9345        let settings = settings.clone();
9346        let virtual_hosted = (settings.endpoint.is_some() || settings.virtual_hosted.is_some())
9347            .then(|| settings.virtual_hosted_style().to_string());
9348        let configs: Vec<(AmazonS3ConfigKey, String)> = [
9349            (AmazonS3ConfigKey::Endpoint, settings.endpoint),
9350            (AmazonS3ConfigKey::AccessKeyId, settings.access_key_id),
9351            (
9352                AmazonS3ConfigKey::SecretAccessKey,
9353                settings.secret_access_key,
9354            ),
9355            (AmazonS3ConfigKey::Token, settings.session_token),
9356            (AmazonS3ConfigKey::Region, settings.region),
9357            (AmazonS3ConfigKey::VirtualHostedStyleRequest, virtual_hosted),
9358            (
9359                AmazonS3ConfigKey::SkipSignature,
9360                settings.skip_signature.then(|| "true".to_string()),
9361            ),
9362        ]
9363        .into_iter()
9364        .filter_map(|(key, value)| value.map(|v| (key, v)))
9365        .chain([(
9366            AmazonS3ConfigKey::Client(crate::user_agent::CLIENT_KEY),
9367            crate::user_agent::get(),
9368        )])
9369        .collect();
9370        CloudOptions::default().with_aws(configs)
9371    }
9372
9373    /// The bucket and key of an `s3://bucket/key` or `gs://bucket/key` URL. The key
9374    /// is empty for a bucket root.
9375    #[cfg(feature = "cloud")]
9376    fn cloud_bucket_and_key(url: &str) -> Result<(String, String)> {
9377        if let Some((_, container, key)) = source::azure_parts(url) {
9378            return Ok((container, key.trim_matches('/').to_string()));
9379        }
9380        crate::cloud_browse::split_bucket_url(url)
9381            .map(|(_, bucket, key)| (bucket, key))
9382            .ok_or_else(|| {
9383                color_eyre::eyre::eyre!("URL must be s3://bucket/key or gs://bucket/key")
9384            })
9385    }
9386
9387    /// The store Polars itself will scan `url` through, from its cache keyed on the
9388    /// bucket and `options`, so the footer read, the size probe and a download share
9389    /// one credential chain, TLS client and connection pool with the scan instead of
9390    /// each building a store of their own.
9391    #[cfg(feature = "cloud")]
9392    fn polars_object_store(
9393        url: &str,
9394        options: &CloudOptions,
9395        runtime: &tokio::runtime::Handle,
9396    ) -> Result<Arc<dyn object_store::ObjectStore>> {
9397        let url = url.to_string();
9398        let options = options.clone();
9399        wait_on_runtime(runtime, async move {
9400            let (_, store) = polars::io::cloud::build_object_store(
9401                PlRefPath::new(url.as_str()),
9402                Some(&options),
9403                false,
9404            )
9405            .await?;
9406            polars::prelude::PolarsResult::Ok(store.to_dyn_object_store().await.into_owned())
9407        })
9408        .ok_or_else(|| color_eyre::eyre::eyre!("cancelled"))?
9409        .map_err(|e| color_eyre::eyre::eyre!("Object store config failed: {}", e))
9410    }
9411
9412    /// Human-readable byte size, for the download confirmation and the load's progress.
9413    fn format_bytes(n: u64) -> String {
9414        const KB: u64 = 1024;
9415        const MB: u64 = KB * 1024;
9416        const GB: u64 = MB * 1024;
9417        const TB: u64 = GB * 1024;
9418        if n >= TB {
9419            format!("{:.2} TB", n as f64 / TB as f64)
9420        } else if n >= GB {
9421            format!("{:.2} GB", n as f64 / GB as f64)
9422        } else if n >= MB {
9423            format!("{:.2} MB", n as f64 / MB as f64)
9424        } else if n >= KB {
9425            format!("{:.2} KB", n as f64 / KB as f64)
9426        } else {
9427            format!("{} bytes", n)
9428        }
9429    }
9430
9431    /// Build an HTTP agent with a total time budget.
9432    ///
9433    /// ureq 3 moved timeouts off the request and onto agent configuration, so
9434    /// every request has to come from an agent to be bounded at all. Leaving a
9435    /// request unbounded would mean a remote that accepts a connection and then
9436    /// dribbles bytes forever hangs the whole TUI, and the user's only way out
9437    /// is to kill the process.
9438    ///
9439    /// `timeout_global` covers the entire exchange rather than individual
9440    /// socket operations, which is the property that matters here: a server
9441    /// that sends one byte every 29 seconds defeats a per-read timeout but not
9442    /// this one.
9443    #[cfg(feature = "http")]
9444    fn http_agent(total: std::time::Duration) -> ureq::Agent {
9445        crate::user_agent::ureq_config()
9446            .timeout_global(Some(total))
9447            .build()
9448            .into()
9449    }
9450
9451    /// What a HEAD says an HTTP(S) file weighs: `None` when it does not say. An error
9452    /// only when the answer settles that the file cannot be had (a 404, no server); a
9453    /// server that refuses HEAD may still send the file.
9454    #[cfg(feature = "http")]
9455    fn fetch_remote_size_http(
9456        url: &str,
9457    ) -> std::result::Result<Option<u64>, crate::error_display::HttpGone> {
9458        let agent = Self::http_agent(std::time::Duration::from_secs(15));
9459        // ureq asks for gzip by default and strips Content-Length from a compressed
9460        // answer, so a server that compresses (GitHub Pages does) reports no size.
9461        // Identity asks for the file's own length, which is what lands on disk.
9462        match agent.head(url).header("Accept-Encoding", "identity").call() {
9463            Ok(r) => Ok(r
9464                .headers()
9465                .get("Content-Length")
9466                .and_then(|v| v.to_str().ok())
9467                .and_then(|s| s.parse::<u64>().ok())),
9468            Err(e) => crate::error_display::http_gone(url, &e).map_or(Ok(None), Err),
9469        }
9470    }
9471
9472    /// The size of one S3 or GCS object, from a HEAD through the shared store.
9473    #[cfg(feature = "cloud")]
9474    fn fetch_remote_size_cloud(
9475        url: &str,
9476        cloud: &crate::config::CloudConfig,
9477        runtime: &tokio::runtime::Handle,
9478    ) -> Result<Option<u64>> {
9479        use object_store::ObjectStoreExt;
9480
9481        let (_bucket, key) = Self::cloud_bucket_and_key(url)?;
9482        if key.is_empty() {
9483            return Ok(None);
9484        }
9485        let (_, _, store) = Self::cloud_store_for(Path::new(url), cloud, runtime)?;
9486        let path = crate::cloud_browse::object_path(&key);
9487        let head = wait_on_runtime(runtime, async move { store.head(&path).await });
9488        Ok(head.and_then(|r| r.ok()).map(|meta| meta.size))
9489    }
9490
9491    /// Download `url` to a temporary file. `stop` ends it early, while the server is
9492    /// sending or while it is silent, and any failure removes the file; see
9493    /// [`crate::download::read_to_temp`].
9494    ///
9495    /// Past `limit` bytes it stops with a [`crate::download::PastLimit`] error.
9496    #[cfg(feature = "http")]
9497    fn download_http_to_temp(
9498        url: &str,
9499        temp_dir: Option<&Path>,
9500        extension: Option<&str>,
9501        limit: Option<u64>,
9502        writer: &crate::unfinished::Writer,
9503    ) -> Result<crate::download::TempDownload> {
9504        use crate::download::StreamError;
9505
9506        let url = url.to_string();
9507        let open = move || {
9508            let agent = Self::http_agent(std::time::Duration::from_secs(300));
9509            // ureq answers a 4xx or 5xx with an error, so every failure is said here.
9510            let response = agent
9511                .get(&url)
9512                .call()
9513                .map_err(|e| crate::error_display::http_message(&url, &e))?;
9514            // No length: ureq hands back a compressed answer decompressed, and the
9515            // Content-Length it came with is the wire's, not the file's.
9516            Ok((response.into_body().into_reader(), None))
9517        };
9518        crate::download::read_to_temp(temp_dir, extension, open, writer, limit).map_err(|error| {
9519            match error {
9520                StreamError::Open(message) => color_eyre::eyre::eyre!(message),
9521                StreamError::Read(e) => {
9522                    color_eyre::eyre::eyre!("Download failed partway. Check your connection: {e}")
9523                }
9524                StreamError::Short { expected, got } => color_eyre::eyre::eyre!(
9525                    "Download failed partway: it ended after {got} of {expected} bytes."
9526                ),
9527                StreamError::Write(report) => report,
9528                StreamError::Cut => color_eyre::eyre::eyre!("Download was cancelled."),
9529            }
9530        })
9531    }
9532
9533    /// Stream one S3, GCS or Azure object to a temporary file, named for the user by
9534    /// its scheme in any error. A few chunks are in memory at a time; see
9535    /// [`crate::download`]. `writer`'s open stopping ends it early, and any failure
9536    /// removes the file.
9537    #[cfg(feature = "cloud")]
9538    fn download_cloud_to_temp(
9539        url: &str,
9540        cloud: &crate::config::CloudConfig,
9541        options: &OpenOptions,
9542        runtime: &tokio::runtime::Handle,
9543        writer: &crate::unfinished::Writer,
9544    ) -> Result<crate::download::TempDownload> {
9545        use crate::download::StreamError;
9546        use object_store::ObjectStoreExt;
9547
9548        let (label, example) = match source::input_source(Path::new(url)) {
9549            source::InputSource::Gcs(_) => ("GCS", "gs://bucket/path/file.csv"),
9550            source::InputSource::Azure(_) => (
9551                "Azure",
9552                "abfss://container@account.dfs.core.windows.net/path/file.csv",
9553            ),
9554            _ => ("S3", "s3://bucket/path/file.csv"),
9555        };
9556        let ext = source::download_suffix(url);
9557        let (_bucket, key) = Self::cloud_bucket_and_key(url)?;
9558        if key.is_empty() {
9559            return Err(crate::error_display::FileError::new(
9560                Path::new(url),
9561                format!("a {label} URL names an object here, such as {example}"),
9562            )
9563            .into());
9564        }
9565        let (_, _, store) = Self::cloud_store_for(Path::new(url), cloud, runtime)?;
9566
9567        let path = crate::cloud_browse::object_path(&key);
9568        let open = async move {
9569            let got = store
9570                .get(&path)
9571                .await
9572                .map_err(|e| crate::error_display::store_message(&e))?;
9573            let len = got.range.end - got.range.start;
9574            Ok((got.into_stream(), Some(len)))
9575        };
9576        let failed = |what: String| -> color_eyre::Report {
9577            crate::error_display::FileError::new(Path::new(url), what).into()
9578        };
9579        crate::download::stream_to_temp(
9580            runtime,
9581            options.temp_dir.as_deref(),
9582            ext.as_deref(),
9583            open,
9584            writer,
9585        )
9586        .map_err(|error| match error {
9587            StreamError::Open(e) => failed(e),
9588            StreamError::Read(e) => failed(format!("the download stopped: {e}")),
9589            StreamError::Short { expected, got } => {
9590                failed(format!("it ended after {got} of {expected} bytes"))
9591            }
9592            StreamError::Write(report) => report,
9593            StreamError::Cut => failed("the download was cancelled".to_string()),
9594        })
9595    }
9596
9597    /// Run the worker of an open's phase, as `load`'s job: its answer goes to the loader.
9598    ///
9599    /// Every phase runs off the event thread. The size probe is a HEAD request: fifteen
9600    /// seconds of timeout for HTTP, unbounded for S3 and GCS, and inline it froze the UI
9601    /// precisely where the user is most likely to want out. Scanning is where the
9602    /// wall-clock time goes — CSV schema inference, and hive directories with many files
9603    /// — and the schema read of a directory reads a footer from each file.
9604    /// The open's scan of `paths`, named `path`: what the frame is, or what has to
9605    /// happen before there is one. Run by the open's `Scan` phase, and by the home
9606    /// screen's preview, which hands what it builds to the open.
9607    pub(crate) fn scan_for_open(
9608        cloud: &crate::config::CloudConfig,
9609        formats: &crate::formats::Registry,
9610        paths: &[PathBuf],
9611        options: OpenOptions,
9612        path: Option<PathBuf>,
9613    ) -> std::result::Result<loading::LoadAnswer, String> {
9614        use loading::LoadAnswer;
9615        let bytes_of = |files: &[PathBuf]| -> u64 {
9616            files
9617                .iter()
9618                .filter_map(|f| std::fs::metadata(f).ok())
9619                .map(|m| m.len())
9620                .sum()
9621        };
9622        // What the read passed over rides back with the options it was asked
9623        // for, so the dataset can say what it left out. Seeded with what the
9624        // caller already knows and overwritten by what the read finds: a
9625        // directory on disk is the read's own answer, because it is the pass
9626        // that decides, while for a prefix in an object store Polars does the
9627        // listing and never sees the other formats — there the home screen's
9628        // listing is the only witness.
9629        let mut report = ReadReport {
9630            left_out: options.left_out.clone(),
9631            files_disagree: options.files_disagree,
9632            format: None,
9633            format_read: None,
9634            read_python: Vec::new(),
9635            sqlite: None,
9636            opened: None,
9637            splits: options.splits.clone(),
9638            delimited: None,
9639            table: None,
9640            guessed: false,
9641            read_notes: Vec::new(),
9642            typing: Default::default(),
9643        };
9644        // A followed file reads every row it can and counts the rest: a row
9645        // that does not fit the schema never stops the follow.
9646        let options = OpenOptions {
9647            ignore_errors: options.ignore_errors || options.follow,
9648            ..options
9649        };
9650        let named = |e: color_eyre::Report| {
9651            crate::error_display::user_message_from_report(&e, path.as_deref())
9652        };
9653        // An Arrow IPC stream followed is read by a scan of its own, not converted;
9654        // NDJSON followed is scanned rather than read whole, by its reader.
9655        let followed_stream = options.follow
9656            && crate::follow::followed_stream(
9657                &paths[0],
9658                Some(crate::follow::format_of(&paths[0], options.format)),
9659                &options,
9660            );
9661        let scan = if followed_stream {
9662            crate::follow::stream::scan(&paths[0])
9663                .map(Scan::from)
9664                .map_err(|e| color_eyre::eyre::eyre!(e))
9665        } else {
9666            Self::build_lazyframe_from_paths_with(cloud, paths, &options, &mut report, formats)
9667        }
9668        // Named as the dataset is: a download by its URL, not its temp file.
9669        .map_err(named)?;
9670        let format = scan.format(report.format.or(options.format));
9671        // Bounded to the complete records, and counted for the watcher. A
9672        // recording that cannot be followed is read as it stands, and goes on.
9673        let recording = options
9674            .spool
9675            .as_ref()
9676            .is_some_and(|handle| handle.spool().tee().is_some());
9677        let (scan, tail) = match scan {
9678            Scan::Frame(lf) if options.follow => {
9679                let format = crate::follow::format_of(&paths[0], format);
9680                let refused = (!followed_stream)
9681                    .then(|| crate::follow::refusal(Some(format), &options))
9682                    .flatten();
9683                match refused {
9684                    Some(_) if recording => (Scan::Frame(lf), None),
9685                    Some(refusal) => return Err(refusal),
9686                    None => {
9687                        let (lf, tail) =
9688                            crate::follow::bound_to_complete(*lf, &paths[0], format, &options)
9689                                .map_err(named)?;
9690                        (Scan::Frame(Box::new(lf)), Some(Arc::new(tail)))
9691                    }
9692                }
9693            }
9694            _ if options.follow && !recording => {
9695                return Err(crate::follow::refusal(format, &options)
9696                    .unwrap_or_else(|| "This file cannot be followed as it grows.".to_string()));
9697            }
9698            scan => (scan, None),
9699        };
9700        let read_mode = scan.read_mode(format, report.format_read.is_some(), &options);
9701        let mut options = OpenOptions {
9702            left_out: report.left_out,
9703            files_disagree: report.files_disagree,
9704            format,
9705            format_read: report.format_read,
9706            sqlite: report.sqlite,
9707            opened: report.opened,
9708            splits: report.splits,
9709            read_python: report.read_python,
9710            read_mode,
9711            tail,
9712            table: report.table.or_else(|| options.table.clone()),
9713            format_guessed: options.format_guessed || report.guessed,
9714            read_notes: report.read_notes,
9715            typing: report.typing,
9716            ..options
9717        };
9718        // The spec's dialect stays with the dataset, so a read again (`H`,
9719        // a decompressed copy) reads as this one did.
9720        if let Some(read) = report.delimited {
9721            read.delimited().apply(&mut options);
9722            options.delimited = Some(read);
9723        }
9724        Ok(match scan {
9725            Scan::Frame(lf) => LoadAnswer::Scanned { lf, path, options },
9726            Scan::Decompress { file, .. } => LoadAnswer::Compressed {
9727                file,
9728                path,
9729                options,
9730            },
9731            Scan::Streams(files) => LoadAnswer::Convert {
9732                what: loading::Conversion::Streams,
9733                bytes: bytes_of(&files),
9734                files,
9735                path,
9736                options,
9737            },
9738            Scan::DecompressSpec { file, choice } => LoadAnswer::CompressedRecords {
9739                file,
9740                path,
9741                choice,
9742                options,
9743            },
9744            Scan::ReadInto { files, format } => LoadAnswer::Convert {
9745                what: loading::Conversion::Text(format),
9746                bytes: bytes_of(&files),
9747                files,
9748                path,
9749                options,
9750            },
9751            Scan::Tables { file, tables, .. } => LoadAnswer::Tables { file, tables, path },
9752            Scan::Unpack {
9753                file,
9754                member,
9755                format,
9756            } => LoadAnswer::Convert {
9757                what: loading::Conversion::Text(format),
9758                bytes: bytes_of(std::slice::from_ref(&file)),
9759                files: vec![file],
9760                path,
9761                options: OpenOptions {
9762                    table: Some(member),
9763                    ..options
9764                },
9765            },
9766            Scan::Hex { file, asked } => LoadAnswer::Hex {
9767                file,
9768                asked,
9769                record_size: options.record_size,
9770            },
9771        })
9772    }
9773
9774    /// The open's schema read of the scan's frame: the dataset, built with everything
9775    /// the open `made`. Run by the open's `ReadSchema` phase, and by the home screen's
9776    /// preview.
9777    pub(crate) fn read_schema_for_open(
9778        lf: LazyFrame,
9779        path: Option<PathBuf>,
9780        options: OpenOptions,
9781        cloud: &crate::config::CloudConfig,
9782        runtime: &tokio::runtime::Handle,
9783        report: &crate::measurements::OpenReport,
9784        made: loading::Made,
9785    ) -> std::result::Result<loading::LoadAnswer, String> {
9786        use loading::LoadAnswer;
9787        let (state, facts, debug_label) =
9788            Self::build_schema_state(lf, path.as_deref(), &options, cloud, runtime, report)
9789                .map_err(|e| crate::error_display::user_message_from_report(&e, path.as_deref()))?;
9790        // Everything the open found, given to the dataset as it is built.
9791        let loading::Made {
9792            download,
9793            converted,
9794            notes,
9795            other_tables,
9796            detail,
9797        } = made;
9798        let mut open_notes = facts.open_notes;
9799        open_notes.extend(notes);
9800        let mut other_tables_found = facts.other_tables;
9801        other_tables_found.extend(other_tables);
9802        let state = state.with_open(OpenFacts {
9803            fetched: Self::fetched(download.as_ref(), path.as_deref()),
9804            download,
9805            converted,
9806            other_tables: other_tables_found,
9807            open_notes,
9808            detail: detail.or(facts.detail),
9809            ..facts
9810        });
9811        Ok(LoadAnswer::SchemaRead {
9812            state: Box::new(state),
9813            path,
9814            options,
9815            debug_label: Some(debug_label),
9816        })
9817    }
9818
9819    fn spawn_load_phase(&mut self, load: loading::LoadId, step: loading::Step) {
9820        use loading::{LoadAnswer, Step};
9821        let job = Job::Load(load);
9822        match step {
9823            #[cfg(any(feature = "http", feature = "cloud"))]
9824            Step::ReadHeaders {
9825                url,
9826                format,
9827                options,
9828                writer,
9829            } => {
9830                let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
9831                self.spawn_job(job, Some("Reading headers..."), move |_| {
9832                    let read = crate::remote_model::read(&url, format, &cloud, &runtime, &|| {
9833                        writer.stopped()
9834                    });
9835                    let crate::remote_model::Read { lf, summary, notes } = match read {
9836                        Ok(read) => read,
9837                        Err(crate::model_files::RangeError::NoRanges) => {
9838                            return Ok(Answer::Load(Box::new(LoadAnswer::NoRanges { options })));
9839                        }
9840                        // The URL in the message may carry a password or a signature.
9841                        Err(crate::model_files::RangeError::Failed(message)) => {
9842                            return Err(crate::logging::redact(&message, &[]));
9843                        }
9844                    };
9845                    let opened = Arc::new(crate::model_files::opened(&summary));
9846                    let options = OpenOptions {
9847                        format: Some(format),
9848                        opened: Some(opened.clone()),
9849                        ..options
9850                    };
9851                    // The table is the headers, in memory: nothing is left to scan.
9852                    let state = Self::schema_state_from_full_scan(
9853                        lf,
9854                        None,
9855                        &OpenOptions {
9856                            hive: false,
9857                            ..options.clone()
9858                        },
9859                    )
9860                    .map_err(|e| crate::error_display::user_message_from_report(&e, Some(&url)))?
9861                    .with_open(OpenFacts {
9862                        detail: opened.detail.clone(),
9863                        open_notes: notes,
9864                        read_as: Some(format),
9865                        ..Default::default()
9866                    });
9867                    Ok(Answer::Load(Box::new(LoadAnswer::SchemaRead {
9868                        state: Box::new(state),
9869                        path: Some(url),
9870                        options,
9871                        debug_label: Some("model headers (ranged)".to_string()),
9872                    })))
9873                });
9874            }
9875            #[cfg(any(feature = "http", feature = "cloud"))]
9876            Step::Probe(pending) => {
9877                #[cfg(feature = "cloud")]
9878                let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
9879                self.spawn_job(job, Some("Checking size..."), move |_| {
9880                    // Arrow in a store: its listing says which objects, and which of
9881                    // them are streams to download.
9882                    #[cfg(feature = "cloud")]
9883                    if let loading::PendingDownload::Arrow { url, .. } = &pending {
9884                        let (_, _, options) = pending.parts();
9885                        let (objects, options) = crate::cloud_arrow::list(
9886                            url, options, &cloud, &runtime,
9887                        )
9888                        .map_err(|e| crate::error_display::user_message_from_report(&e, None))?;
9889                        let size = crate::cloud_arrow::stream_bytes(&objects);
9890                        return Ok(Answer::Load(Box::new(LoadAnswer::Sized(
9891                            loading::PendingDownload::Arrow {
9892                                url: url.clone(),
9893                                objects,
9894                                size: Some(size),
9895                                options,
9896                            },
9897                        ))));
9898                    }
9899                    let size = match &pending {
9900                        #[cfg(feature = "http")]
9901                        loading::PendingDownload::Http { url, .. } => {
9902                            // A file that is not there, or a host that does not
9903                            // answer, ends the open here, not after a question
9904                            // about downloading it.
9905                            Self::fetch_remote_size_http(url).map_err(|gone| gone.message)?
9906                        }
9907                        #[cfg(feature = "cloud")]
9908                        loading::PendingDownload::S3 { url, .. }
9909                        | loading::PendingDownload::Gcs { url, .. }
9910                        | loading::PendingDownload::Azure { url, .. } => {
9911                            Self::fetch_remote_size_cloud(url, &cloud, &runtime).unwrap_or(None)
9912                        }
9913                        #[cfg(feature = "cloud")]
9914                        loading::PendingDownload::Arrow { size, .. } => *size,
9915                    };
9916                    Ok(Answer::Load(Box::new(LoadAnswer::Sized(
9917                        pending.with_size(size),
9918                    ))))
9919                });
9920            }
9921            #[cfg(any(feature = "http", feature = "cloud"))]
9922            Step::Download { pending, writer } => {
9923                // The load's stop flag is raised when it is abandoned or another open
9924                // replaces it, and when the app drops: the download stops at the next
9925                // chunk, or while the source is silent, and removes its file. Quitting
9926                // removes it even if the process ends first (`ExitSweep`).
9927                #[cfg(feature = "cloud")]
9928                let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
9929                let status = match &pending {
9930                    #[cfg(feature = "http")]
9931                    loading::PendingDownload::Http { .. } => "Downloading...",
9932                    #[cfg(feature = "cloud")]
9933                    loading::PendingDownload::S3 { .. } => "Downloading from S3...",
9934                    #[cfg(feature = "cloud")]
9935                    loading::PendingDownload::Gcs { .. } => "Downloading from GCS...",
9936                    #[cfg(feature = "cloud")]
9937                    loading::PendingDownload::Azure { .. } => "Downloading from Azure...",
9938                    #[cfg(feature = "cloud")]
9939                    loading::PendingDownload::Arrow { url, .. } => {
9940                        match source::input_source(Path::new(url)) {
9941                            source::InputSource::Gcs(_) => "Downloading from GCS...",
9942                            source::InputSource::Azure(_) => "Downloading from Azure...",
9943                            _ => "Downloading from S3...",
9944                        }
9945                    }
9946                };
9947                // How much, when the server said: a download nobody was asked about
9948                // says what it is fetching.
9949                let sized = pending
9950                    .parts()
9951                    .1
9952                    .filter(|_| status == "Downloading...")
9953                    .map(|size| format!("Downloading {}...", discover::format_size(size)));
9954                let status = sized.as_deref().unwrap_or(status);
9955                self.spawn_job(job, Some(status), move |_| {
9956                    let (url, _, options) = pending.parts();
9957                    let fetched = match &pending {
9958                        #[cfg(feature = "http")]
9959                        loading::PendingDownload::Http { .. } => {
9960                            let ext = source::download_suffix(url);
9961                            // A download nobody was asked about stops at its limit, when
9962                            // the server did not say its size: a size it said bounds the
9963                            // transfer, and the bytes counted here are decompressed.
9964                            let limit = options
9965                                .download_unasked
9966                                .filter(|_| pending.parts().1.is_none())
9967                                .map(|unasked| unasked.limit);
9968                            Self::download_http_to_temp(
9969                                url,
9970                                options.temp_dir.as_deref(),
9971                                ext.as_deref(),
9972                                limit,
9973                                &writer,
9974                            )
9975                            .map(|file| (file, options.clone()))
9976                        }
9977                        #[cfg(feature = "cloud")]
9978                        loading::PendingDownload::S3 { .. }
9979                        | loading::PendingDownload::Gcs { .. }
9980                        | loading::PendingDownload::Azure { .. } => {
9981                            Self::download_cloud_to_temp(url, &cloud, options, &runtime, &writer)
9982                                .map(|file| (file, options.clone()))
9983                        }
9984                        // Its streams, converted as they arrive; its IPC files stay put.
9985                        #[cfg(feature = "cloud")]
9986                        loading::PendingDownload::Arrow { objects, .. } => {
9987                            crate::cloud_arrow::download(
9988                                objects, options, &cloud, &runtime, &writer,
9989                            )
9990                            .map(|(file, parts)| {
9991                                let options = OpenOptions {
9992                                    format: Some(FileFormat::Arrow),
9993                                    hive: false,
9994                                    arrow_parts: Some(Arc::new(parts)),
9995                                    ..options.clone()
9996                                };
9997                                (file, options)
9998                            })
9999                        }
10000                    };
10001                    let (download, options) = match fetched {
10002                        Err(e) if e.downcast_ref::<crate::download::PastLimit>().is_some() => {
10003                            return Ok(Answer::Load(Box::new(LoadAnswer::PastLimit(pending))));
10004                        }
10005                        fetched => fetched.map_err(|e| {
10006                            crate::error_display::user_message_from_report(&e, None)
10007                        })?,
10008                    };
10009                    Ok(Answer::Load(Box::new(LoadAnswer::Downloaded {
10010                        download,
10011                        options,
10012                    })))
10013                });
10014            }
10015            Step::Spool {
10016                options,
10017                writer,
10018                read,
10019            } => {
10020                // The read is a thread of its own, so a producer gone quiet does not hold
10021                // up the stop: Ctrl+O and quitting remove the partial file at once.
10022                let piped = self.stdin_reader.take();
10023                let stdout = self.stdout_pass.take();
10024                self.spawn_job(job, Some("Reading stdin..."), move |_| {
10025                    let open = move || -> crate::download::Opened<Box<dyn std::io::Read + Send>> {
10026                        Ok((piped.unwrap_or_else(|| Box::new(std::io::stdin())), None))
10027                    };
10028                    // Followed, the copy goes on behind the first rows; recorded, it
10029                    // goes to the file the user named.
10030                    // And read as it arrives when what it holds can be.
10031                    let (download, options) = if options.follow
10032                        || options.tee.is_some()
10033                        || crate::stdin::may_read_as_it_arrives(&options)
10034                    {
10035                        match crate::follow::spool(open, options, &writer, &read, stdout)? {
10036                            (crate::follow::Spooled::Temp(download), options) => {
10037                                (download, options)
10038                            }
10039                            (crate::follow::Spooled::Kept(file), options) => {
10040                                return Ok(Answer::Load(Box::new(LoadAnswer::Recorded {
10041                                    file,
10042                                    options,
10043                                })));
10044                            }
10045                        }
10046                    } else {
10047                        crate::stdin::spool(open, options, &writer, &read)?
10048                    };
10049                    Ok(Answer::Load(Box::new(LoadAnswer::Spooled {
10050                        download,
10051                        options,
10052                    })))
10053                });
10054            }
10055            Step::FetchSpec {
10056                url,
10057                options,
10058                writer,
10059            } => {
10060                #[cfg(any(feature = "http", feature = "cloud"))]
10061                let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
10062                self.spawn_job(job, Some("Reading spec..."), move |_| {
10063                    #[cfg(any(feature = "http", feature = "cloud"))]
10064                    let fetched = crate::remote_model::fetch_small(
10065                        &url,
10066                        crate::formats::MAX_SPEC_BYTES,
10067                        &cloud,
10068                        &runtime,
10069                        &|| writer.stopped(),
10070                    );
10071                    #[cfg(not(any(feature = "http", feature = "cloud")))]
10072                    let fetched: std::result::Result<Option<Vec<u8>>, String> = {
10073                        let _ = &writer;
10074                        Err(crate::error_display::file_message(
10075                            &url,
10076                            "this build reads no URLs",
10077                        ))
10078                    };
10079                    // The URL in the message may carry a password or a signature.
10080                    let bytes = fetched
10081                        .map_err(|message| crate::logging::redact(&message, &[]))?
10082                        .ok_or_else(|| {
10083                            crate::logging::redact(
10084                                &crate::error_display::file_message(
10085                                    &url,
10086                                    &format!(
10087                                        "a format spec is at most {}",
10088                                        crate::formats::MAX_SPEC_SAID
10089                                    ),
10090                                ),
10091                                &[],
10092                            )
10093                        })?;
10094                    let spec = crate::formats::Spec::from_bytes(&bytes, &url)
10095                        .map_err(|e| crate::logging::redact(&e.to_string(), &[]))?;
10096                    Ok(Answer::Load(Box::new(LoadAnswer::SpecFetched {
10097                        spec: Arc::new(spec),
10098                        options,
10099                    })))
10100                });
10101            }
10102            Step::DecompressRecords {
10103                file,
10104                path,
10105                choice,
10106                options,
10107                writer,
10108            } => {
10109                self.spawn_job(job, Some("Decompressing..."), move |_| {
10110                    let failed = |e: color_eyre::Report| {
10111                        crate::error_display::user_message_from_report(&e, Some(path.as_path()))
10112                    };
10113                    let compression = options
10114                        .compression
10115                        .or_else(|| CompressionFormat::from_extension(&file))
10116                        .ok_or_else(|| format!("{} is not compressed", path.display()))?;
10117                    let temp_dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
10118                    let copy =
10119                        DataTableState::decompress_to_copy(&file, compression, &temp_dir, &writer)
10120                            .map_err(failed)?;
10121                    Ok(Answer::Load(Box::new(LoadAnswer::DecompressedRecords {
10122                        copy,
10123                        path,
10124                        choice,
10125                        options,
10126                    })))
10127                });
10128            }
10129            Step::ReadRecords {
10130                copy,
10131                path,
10132                choice,
10133                options,
10134            } => {
10135                self.spawn_job(job, Some("Reading records..."), move |_| {
10136                    let named = path
10137                        .file_name()
10138                        .map(|n| n.to_string_lossy().into_owned())
10139                        .unwrap_or_default();
10140                    let read = crate::formats::read(&copy, &named, choice)?;
10141                    let lf = Arc::clone(&read.records).into_lazy().map_err(|e| {
10142                        crate::error_display::user_message_from_report(
10143                            &color_eyre::eyre::eyre!(e),
10144                            Some(path.as_path()),
10145                        )
10146                    })?;
10147                    Ok(Answer::Load(Box::new(LoadAnswer::Scanned {
10148                        lf: Box::new(lf),
10149                        path: Some(path),
10150                        options: OpenOptions {
10151                            format_read: Some(Arc::new(read)),
10152                            ..options
10153                        },
10154                    })))
10155                });
10156            }
10157            Step::Decompress {
10158                file,
10159                path,
10160                options,
10161                writer,
10162                download,
10163            } => {
10164                // Only delimited text and lines come this way, the format said by the
10165                // loader or the scan.
10166                let options = OpenOptions {
10167                    format: options.format.or(Some(FileFormat::TEXT)),
10168                    ..options
10169                };
10170                let formats = self.formats.clone();
10171                self.spawn_job(job, Some("Decompressing..."), move |_| {
10172                    let failed = |e: color_eyre::Report| {
10173                        crate::error_display::user_message_from_report(&e, Some(path.as_path()))
10174                    };
10175                    let options =
10176                        Self::with_delimited_spec(&file, options, &formats).map_err(failed)?;
10177                    let lines = options.delimited.is_none()
10178                        && options.format.is_some_and(FileFormat::is_lines);
10179                    let (state, opened) = if lines {
10180                        let (state, opened) =
10181                            DataTableState::from_lines_decompressed(&file, &options, &writer)
10182                                .map_err(failed)?;
10183                        (state, Some(opened))
10184                    } else {
10185                        let state = Self::decompressed_delimited_state(&file, &options, &writer)
10186                            .map_err(failed)?;
10187                        (state, None)
10188                    };
10189                    let mut open_notes = options
10190                        .delimited
10191                        .as_ref()
10192                        .map(|read| read.notes())
10193                        .unwrap_or_default();
10194                    open_notes.extend(opened.iter().flat_map(|o| o.notes.iter().cloned()));
10195                    let state = state.with_open(OpenFacts {
10196                        fetched: Self::fetched(download.as_ref(), Some(&path)),
10197                        download,
10198                        open_notes,
10199                        records: opened.and_then(|o| o.window),
10200                        delimited: options.delimited.clone(),
10201                        read_as: options.format,
10202                        // The loader sends a compressed file here without a scan.
10203                        read_mode: options.format.and_then(|f| {
10204                            f.read_mode(crate::Stored::Compressed {
10205                                in_memory: options.decompress_in_memory,
10206                            })
10207                        }),
10208                        ..Default::default()
10209                    });
10210                    Ok(Answer::Load(Box::new(LoadAnswer::SchemaRead {
10211                        state: Box::new(state),
10212                        path: Some(path),
10213                        options,
10214                        debug_label: Some("decompressed delimited".to_string()),
10215                    })))
10216                });
10217            }
10218            Step::Convert {
10219                what,
10220                files,
10221                path,
10222                options,
10223                writer,
10224                read,
10225            } => {
10226                // The load's stop flag ends it at the next record batch or chunk,
10227                // removing its files; quitting removes them even if the process ends
10228                // first.
10229                let formats = self.formats.clone();
10230                self.spawn_job(job, Some(what.status()), move |_| {
10231                    let named = |e: color_eyre::Report| {
10232                        crate::error_display::user_message_from_report(&e, path.as_deref())
10233                    };
10234                    let converted = match what {
10235                        loading::Conversion::Streams => {
10236                            let converted = crate::ipc_stream::convert(
10237                                &files,
10238                                options.temp_dir.as_deref(),
10239                                &writer,
10240                                &read,
10241                            )
10242                            .map_err(named)?;
10243                            loading::Converted::Streams {
10244                                file: converted.file,
10245                                parts: converted.parts,
10246                            }
10247                        }
10248                        loading::Conversion::Text(format) => {
10249                            let display = path.clone().unwrap_or_else(|| files[0].clone());
10250                            let (converted, detail) =
10251                                crate::readers::convert(&crate::readers::ConvertIn {
10252                                    files: &files,
10253                                    display: &display,
10254                                    format,
10255                                    options: &options,
10256                                    formats: &formats,
10257                                    writer: &writer,
10258                                    read: &read,
10259                                })
10260                                .map_err(named)?;
10261                            loading::Converted::Frame {
10262                                files: converted.files,
10263                                lf: Box::new(converted.lf),
10264                                notes: converted.notes,
10265                                other_tables: converted.other_tables,
10266                                detail,
10267                            }
10268                        }
10269                    };
10270                    Ok(Answer::Load(Box::new(LoadAnswer::Converted {
10271                        converted,
10272                        path,
10273                        options,
10274                    })))
10275                });
10276            }
10277            Step::Scan {
10278                paths,
10279                options,
10280                display,
10281                status,
10282            } => {
10283                let cloud = self.app_config.cloud.clone();
10284                let formats = self.formats.clone();
10285                // A download is scanned from a temp path the user never typed and would not
10286                // recognise; the URL they did type is what names the dataset.
10287                let path = display.or_else(|| paths.first().cloned());
10288                self.reads.scans += 1;
10289                self.spawn_job(job, Some(status), move |_| {
10290                    Self::scan_for_open(&cloud, &formats, &paths, options, path)
10291                        .map(|answer| Answer::Load(Box::new(answer)))
10292                });
10293            }
10294            Step::ReadSchema {
10295                lf,
10296                path,
10297                options,
10298                progress,
10299                made,
10300            } => {
10301                self.debug.schema_load = None;
10302                let cloud = self.app_config.cloud.clone();
10303                let runtime = self.runtime.clone();
10304                let report = crate::measurements::OpenReport {
10305                    progress,
10306                    meter: Arc::new(crate::measurements::Meter::default()),
10307                    remembered: Some(self.cache.clone()),
10308                    writes: self.cache_writes.clone(),
10309                };
10310                self.spawn_job(job, Some("Reading schema..."), move |_| {
10311                    Self::read_schema_for_open(*lf, path, options, &cloud, &runtime, &report, made)
10312                        .map(|answer| Answer::Load(Box::new(answer)))
10313                });
10314            }
10315            Step::Nothing
10316            | Step::Crash(_)
10317            | Step::Install(_)
10318            | Step::Failed(_)
10319            | Step::Tables(_)
10320            | Step::Hex(_) => {
10321                unreachable!("not a phase with a worker")
10322            }
10323            #[cfg(any(feature = "http", feature = "cloud"))]
10324            Step::Ask(_) => unreachable!("not a phase with a worker"),
10325            Step::AskRead(_) => unreachable!("not a phase with a worker"),
10326        }
10327    }
10328
10329    /// What the user is asked before files past `[read] memory_warning` are
10330    /// read whole into memory: `big.json: JSON reads 2.1 GB into memory`.
10331    fn in_memory_confirmation_message(read: &loading::InMemory) -> String {
10332        let what = match read.files {
10333            1 => format!(
10334                "{}: {} reads",
10335                read.name
10336                    .file_name()
10337                    .map(|n| n.to_string_lossy().into_owned())
10338                    .unwrap_or_else(|| read.name.display().to_string()),
10339                read.format.title()
10340            ),
10341            n => format!("{n} {} files read", read.format.title()),
10342        };
10343        format!(
10344            "{what} {} into memory before the table appears.\n\nRead it?",
10345            Self::format_bytes(read.bytes)
10346        )
10347    }
10348
10349    /// What the user is being asked to agree to before a remote file is downloaded.
10350    #[cfg(any(feature = "http", feature = "cloud"))]
10351    fn download_confirmation_message(
10352        pending: &loading::PendingDownload,
10353        note: Option<&str>,
10354    ) -> String {
10355        let (url, size, options) = pending.parts();
10356        let size_str = size
10357            .map(Self::format_bytes)
10358            .unwrap_or_else(|| "unknown".to_string());
10359        let dest_dir = options
10360            .temp_dir
10361            .as_deref()
10362            .map(|p| p.display().to_string())
10363            .unwrap_or_else(|| std::env::temp_dir().display().to_string());
10364        let note = note.map(|note| format!("{note}\n\n")).unwrap_or_default();
10365        // Only a store's Arrow streams are downloaded, and converted as they arrive.
10366        let files = match pending.arrow_files() {
10367            Some((1, 0)) => "Arrow stream: converted as it downloads\n".to_string(),
10368            Some((streams, 0)) => {
10369                format!("Files: {streams} Arrow streams, converted as they download\n")
10370            }
10371            Some((streams, in_place)) => {
10372                let streams = match streams {
10373                    1 => "1 Arrow stream, converted as it downloads".to_string(),
10374                    n => format!("{n} Arrow streams, converted as they download"),
10375                };
10376                let in_place = match in_place {
10377                    1 => "1 IPC file read in place".to_string(),
10378                    n => format!("{n} IPC files read in place"),
10379                };
10380                format!("Files: {streams}; {in_place}\n")
10381            }
10382            None => String::new(),
10383        };
10384        format!(
10385            "{note}URL: {url}\n{files}File size: {size_str}\nDestination: {dest_dir} (temporary file)\n\nContinue with download?"
10386        )
10387    }
10388
10389    /// Take the offer on the note the cursor is on: read its column as text.
10390    ///
10391    /// Only a note that carries the offer has one, and the offer is taken off a note
10392    /// datui could not act on, so the `Ok(false)` arms here are for a note that has
10393    /// gone stale under the cursor rather than for anything to tell the user about. A
10394    /// failure is the scan's, and is shown the way any other failed read is.
10395    fn read_the_selected_note_s_column_as_text(&mut self) {
10396        let Some(state) = self.data_table_state.as_mut() else {
10397            return;
10398        };
10399        let notes = state.notes();
10400        let Some(column) = notes
10401            .get(self.info_modal.notes_selected_index)
10402            .and_then(|note| note.read_as_text.clone())
10403        else {
10404            return;
10405        };
10406        // Rebuilt here, read off the UI thread. A failure is left showing on the state.
10407        if let Ok(true) = state.deferred(|s| s.read_column_as_text(&column)) {
10408            // The note that offered this is gone and the list is shorter, so the
10409            // cursor would otherwise sit past the end. Kept as near to where the
10410            // user left it as the shorter list allows, rather than thrown to the
10411            // top: one or two notes went, not all of them.
10412            let notes = state.notes().len();
10413            self.info_modal.notes_selected_index = self
10414                .info_modal
10415                .notes_selected_index
10416                .min(notes.saturating_sub(1));
10417            self.info_modal.notes_scroll_offset = 0;
10418            self.spawn_async_collect(Self::LOADING_BUFFER);
10419        }
10420    }
10421
10422    fn hoist_partition_columns(
10423        lf: LazyFrame,
10424        schema: &Schema,
10425        partition_columns: &[String],
10426        drifts: bool,
10427    ) -> LazyFrame {
10428        hoist_partition_columns(lf, schema, partition_columns, drifts)
10429    }
10430}
10431
10432/// Put hive partition columns first, ahead of the file's own columns. `drifts` keeps
10433/// the scan's hidden drift column, which the select would otherwise drop.
10434///
10435/// A free function rather than a method: rebuilding the scan to read a column as text
10436/// has to put the columns back the same way, and it happens on the table's state
10437/// rather than on the app.
10438pub(crate) fn hoist_partition_columns(
10439    lf: LazyFrame,
10440    schema: &Schema,
10441    partition_columns: &[String],
10442    drifts: bool,
10443) -> LazyFrame {
10444    if partition_columns.is_empty() {
10445        return lf;
10446    }
10447    let mut exprs: Vec<_> = partition_columns
10448        .iter()
10449        .map(|s| col(s.as_str()))
10450        .chain(
10451            schema
10452                .iter_names()
10453                .map(|s| s.to_string())
10454                .filter(|c| !partition_columns.contains(c))
10455                .map(|s| col(s.as_str())),
10456        )
10457        .collect();
10458    if drifts {
10459        exprs.push(col(crate::schema_union::DRIFT_COLUMN));
10460    }
10461    lf.select(exprs)
10462}
10463
10464/// A number for each walk the home search starts, so scorings of one are never taken
10465/// for another's, even when the two walked the same place.
10466fn next_search_epoch() -> u64 {
10467    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
10468    NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
10469}
10470
10471/// What a pass behind a staged open reported, and which dataset it was reading for.
10472/// `None` where the footers are: a pass that could not read them says so, so the
10473/// dataset stops waiting.
10474/// Bytes of a text file indexed per step behind its first rows, between which the
10475/// indexing looks whether it is still wanted.
10476const INDEX_STEP: usize = 16 << 20;
10477
10478type FootersReported = Option<(u64, Option<crate::widgets::datatable::FootersFound>)>;
10479
10480impl App {
10481    /// Schema for a local directory of Parquet files: every column any of them has, from
10482    /// their footers, instead of `collect_schema()` over the whole set or one file's
10483    /// columns standing in for all.
10484    ///
10485    /// As a cloud prefix opens: past one wave of footers the two ends open the dataset
10486    /// and the rest are read behind it, joining when they land, and a directory whose
10487    /// listing has not changed since its footers were last all read opens from what
10488    /// they said then. On a network mount each footer is round trips, and a Hive tree
10489    /// is thousands of footers. `None` when the path is not that shape, or when nothing
10490    /// could be read — either way the caller falls back to the general scan, which
10491    /// reports the error properly if there is one.
10492    fn schema_state_from_local_hive(
10493        path: Option<&Path>,
10494        options: &OpenOptions,
10495        report: &crate::measurements::OpenReport,
10496    ) -> Option<(DataTableState, OpenFacts)> {
10497        if !options.single_spine_schema {
10498            return None;
10499        }
10500        let p = path.filter(|p| p.is_dir() && options.hive)?;
10501        dataset_files::open(Arc::new(dataset_files::LocalFiles::new(p)), options, report)
10502    }
10503
10504    /// The same one-file trick against an object store. This is the route that used to
10505    /// block the UI thread on a network round trip.
10506    #[cfg(feature = "cloud")]
10507    fn schema_state_from_cloud_hive(
10508        path: Option<&Path>,
10509        options: &OpenOptions,
10510        cloud: &crate::config::CloudConfig,
10511        runtime: &tokio::runtime::Handle,
10512        report: &crate::measurements::OpenReport,
10513    ) -> Option<(DataTableState, OpenFacts)> {
10514        // A prefix of Arrow files is read from its download (`cloud_arrow`).
10515        if !options.single_spine_schema || options.format == Some(FileFormat::Arrow) {
10516            return None;
10517        }
10518        // Unlike the local path this does not require --hive: a directory or glob URL
10519        // is already a hive scan by shape.
10520        let p = path.filter(|p| {
10521            let s = p.as_os_str().to_string_lossy();
10522            home::is_object_store_url(p) && (options.hive || source::is_prefix_or_glob(&s))
10523        })?;
10524
10525        let (full, cloud_opts, store) = Self::cloud_store_for(p, cloud, runtime).ok()?;
10526        let (_bucket, key) = Self::cloud_bucket_and_key(&full).ok()?;
10527        Self::schema_state_from_cloud_hive_with(
10528            full, key, store, cloud_opts, options, runtime, report,
10529        )
10530    }
10531
10532    /// The same, against a store already built.
10533    ///
10534    /// Split out so a test can hand it an in-memory store and cover the choice between
10535    /// the two routes below — including that each is given the counter it was called
10536    /// with, rather than one of its own.
10537    #[cfg(feature = "cloud")]
10538    fn schema_state_from_cloud_hive_with(
10539        full: String,
10540        key: String,
10541        store: Arc<dyn object_store::ObjectStore>,
10542        cloud_opts: CloudOptions,
10543        options: &OpenOptions,
10544        runtime: &tokio::runtime::Handle,
10545        report: &crate::measurements::OpenReport,
10546    ) -> Option<(DataTableState, OpenFacts)> {
10547        // Every file listed once, and the scan, the schema and the count all work from
10548        // that list — for a glob as much as for a prefix. datui expands the glob
10549        // itself: it lists the literal part of the key and matches the rest, so a glob
10550        // is an ordinary list of files by the time anything else sees it, and gets the
10551        // schema union, the row count, the notes and the measurements that a prefix
10552        // gets.
10553        //
10554        // The star cannot be handed to the object store. A listing prefix is a literal
10555        // string, so `data/*.parquet` matches nothing and the open falls through to a
10556        // whole-dataset scan with none of the above — which is what used to happen, for
10557        // every glob, silently (#228).
10558        let pattern = full.contains('*').then(|| {
10559            globset::GlobBuilder::new(&key)
10560                .literal_separator(true)
10561                .build()
10562                .map(|g| g.compile_matcher())
10563        });
10564        let pattern = match pattern {
10565            // A pattern datui cannot read is not one it should guess at.
10566            Some(Err(_)) => return None,
10567            Some(Ok(matcher)) => Some(matcher),
10568            None => None,
10569        };
10570        let listed = cloud_hive::prefix_of_glob(&key).to_string();
10571        Self::schema_state_from_cloud_files(
10572            CloudTarget {
10573                full: &full,
10574                key: listed,
10575                pattern: pattern.as_ref(),
10576            },
10577            store,
10578            cloud_opts,
10579            options,
10580            runtime,
10581            report,
10582        )
10583    }
10584
10585    /// A cloud prefix of Parquet files as one dataset, from a single listing of it.
10586    ///
10587    /// The files are scanned by name, so Polars does not list the prefix again, and
10588    /// leniently (see `cloud_hive::lenient_scan`), since files written years apart
10589    /// differ. The state keeps the list, so the count reads footers rather than data
10590    /// and a buffer reads only the files holding its rows (see `RemoteFiles`).
10591    #[cfg(feature = "cloud")]
10592    fn schema_state_from_cloud_files(
10593        target: CloudTarget<'_>,
10594        store: Arc<dyn object_store::ObjectStore>,
10595        cloud_opts: CloudOptions,
10596        options: &OpenOptions,
10597        runtime: &tokio::runtime::Handle,
10598        report: &crate::measurements::OpenReport,
10599    ) -> Option<(DataTableState, OpenFacts)> {
10600        let CloudTarget { full, key, pattern } = target;
10601        dataset_files::open(
10602            Arc::new(dataset_files::StoreFiles::new(
10603                full,
10604                key,
10605                pattern.cloned(),
10606                store,
10607                cloud_opts,
10608                runtime,
10609            )),
10610            options,
10611            report,
10612        )
10613    }
10614
10615    /// What the home screen can say about one object opened from a bucket: its rows
10616    /// and columns from the footer the open read, under the URL it resolved to. The
10617    /// object's size is not known here — the footer is read from the tail — so the
10618    /// record carries none, and the row shows none. Its `mtime` is the time of the
10619    /// open: a remote record is never fingerprinted by it, and the index evicts its
10620    /// oldest `mtime` first, so a zero would make these the first to go.
10621    #[cfg(feature = "cloud")]
10622    fn record_cloud_object_facts(
10623        cache: Option<&crate::cache::CacheManager>,
10624        full: &str,
10625        footer: &cloud_hive::FileFooter,
10626    ) {
10627        let Some(cache) = cache else {
10628            return;
10629        };
10630        let columns: Vec<String> = footer
10631            .schema
10632            .iter_names()
10633            .map(|name| name.to_string())
10634            .collect();
10635        cache.record_dataset_facts(&[(
10636            PathBuf::from(full),
10637            crate::cache::DatasetFacts {
10638                mtime: std::time::SystemTime::now()
10639                    .duration_since(std::time::UNIX_EPOCH)
10640                    .map(|d| d.as_secs())
10641                    .unwrap_or_default(),
10642                size: 0,
10643                rows: Some(footer.rows()),
10644                cols: Some(columns.len()),
10645                cols_sampled: false,
10646                columns,
10647                kind: Some(discover::EntryKind::File),
10648                classified_by: discover::CLASSIFIER_VERSION,
10649                cost: discover::Cost {
10650                    row_groups: Some(footer.row_group_rows.len()),
10651                    ..Default::default()
10652                },
10653                holds: Default::default(),
10654            },
10655        )]);
10656    }
10657
10658    /// General schema route: ask the frame itself. Slow for a wide hive dataset, which
10659    /// is the reason this whole phase belongs on a background thread.
10660    fn schema_state_from_full_scan(
10661        mut lf: LazyFrame,
10662        path: Option<&Path>,
10663        options: &OpenOptions,
10664    ) -> Result<DataTableState> {
10665        let schema = lf
10666            .collect_schema()
10667            .map_err(color_eyre::eyre::Report::from)?;
10668        let partition_columns =
10669            match path.filter(|p| options.hive && (p.is_dir() || source::expands_as_glob(p))) {
10670                Some(p) => DataTableState::discover_hive_partition_columns(p)
10671                    .into_iter()
10672                    .filter(|c| schema.contains(c.as_str()))
10673                    .collect::<Vec<_>>(),
10674                None => Vec::new(),
10675            };
10676        let lf = Self::hoist_partition_columns(lf, &schema, &partition_columns, false);
10677        let part_cols = (!partition_columns.is_empty()).then_some(partition_columns);
10678        DataTableState::from_schema_and_lazyframe(schema, lf, options, part_cols)
10679    }
10680
10681    /// Build the table state for a loaded frame, by the cheapest route that applies.
10682    ///
10683    /// Returns the state and a label naming the route it came from, for the debug
10684    /// overlay. Takes its config by value so all of it can run off the UI thread.
10685    fn build_schema_state(
10686        lf: LazyFrame,
10687        path: Option<&Path>,
10688        options: &OpenOptions,
10689        cloud: &crate::config::CloudConfig,
10690        runtime: &tokio::runtime::Handle,
10691        report: &crate::measurements::OpenReport,
10692    ) -> Result<(DataTableState, OpenFacts, String)> {
10693        // The facts carry the meter of the route that actually built the dataset, so it
10694        // is installed with the dataset and nothing else can reach it. An open that
10695        // fails never gets here, which is what keeps the dataset still on screen
10696        // showing its own figures.
10697        let (state, mut facts, label) =
10698            Self::schema_state_by_route(lf, path, options, cloud, runtime, report)?;
10699        // What the open did, as against what it found. The one place both are known:
10700        // the scan has reported what it passed over, the caller has said whether this
10701        // is a lake table's plain files, and the state that will carry the notes is in
10702        // hand. See `DataTableState::open_notes` for why they are not the other notes.
10703        // A delimited file read with a header whose names are all numbers: its first
10704        // row of data, most likely, which `H` reads as data instead.
10705        let names_look_like_data = options.format.and_then(FileFormat::separator).is_some()
10706            && options.has_header != Some(false)
10707            && !crate::schema_union::names_are_names(
10708                &state
10709                    .schema()
10710                    .iter_names()
10711                    .map(|n| n.to_string())
10712                    .collect::<Vec<_>>(),
10713            );
10714        facts.open_notes = crate::notes::from_the_open(
10715            &options.left_out,
10716            options.read_as_plain_files_of,
10717            options.files_disagree,
10718            names_look_like_data,
10719        );
10720        // And the half of it that cannot be missed: the row count on screen is a true
10721        // count of the files and a wrong one of the table.
10722        facts.not_the_table = options.read_as_plain_files_of;
10723        if let Some(splits) = &options.splits {
10724            facts.other_tables = splits.others.clone();
10725            facts
10726                .open_notes
10727                .extend(crate::notes::map_caches(splits.caches));
10728        }
10729        if let Some(read) = &options.format_read {
10730            facts.open_notes.extend(read.notes());
10731            facts.format_read = Some(read.clone());
10732        }
10733        if let Some(opened) = &options.opened {
10734            facts.records = opened.window.clone();
10735            facts.detail = opened.detail.clone();
10736            facts.other_tables = opened.other_tables.clone();
10737            facts.open_notes.extend(opened.notes.iter().cloned());
10738            facts.units = opened.units.clone();
10739            facts.indexing = opened.indexing.clone();
10740            facts.numbering = opened.numbering.clone();
10741        }
10742        if let Some(sqlite) = &options.sqlite {
10743            facts.pushdown = Some(sqlite.pushdown.clone());
10744            facts.hold = sqlite.hold.lock().ok().and_then(|mut hold| hold.take());
10745            facts.other_tables = sqlite.other_tables.clone();
10746        }
10747        if let Some(read) = &options.delimited {
10748            facts.open_notes.extend(read.notes());
10749            facts.delimited = Some(read.clone());
10750        }
10751        facts.open_notes.extend(options.read_notes.iter().cloned());
10752        facts.typing = options.typing.clone();
10753        facts.read_mode = options.read_mode;
10754        facts.read_as = options.format;
10755        // The display path of a downloaded object is its URL too; only a scan that
10756        // really reads the object store in place buffers like one.
10757        // Arrow in a store reads its IPC files in place, and its streams from their
10758        // download (`cloud_arrow`).
10759        facts.remote_source = match &options.arrow_parts {
10760            Some(parts) => parts.iter().any(|part| {
10761                matches!(part, crate::ipc_stream::Part::InPlace(p) if source::is_remote_url(p))
10762            }),
10763            None => path.is_some_and(source::scans_in_place),
10764        };
10765        // The cheap footer-sum row count, for a local Parquet hive directory. Asked
10766        // here because a stat on a mount that has stopped answering hangs its thread.
10767        // A directory read as another format counts its rows by a scan: its footers
10768        // are not Parquet's.
10769        // Not for one read by file: its counter reads only the footers the open did not.
10770        if options.hive
10771            && facts.remote_files.is_none()
10772            && options.format.is_none_or(|f| f == FileFormat::Parquet)
10773            && let Some(dir) = path.filter(|p| !source::is_remote_url(p) && p.is_dir())
10774        {
10775            facts.parquet_count_dir = Some(dir.to_path_buf());
10776        }
10777        Ok((state, facts, label))
10778    }
10779
10780    /// Scan a prefix in an object store with the reader its format calls for.
10781    ///
10782    /// Every cloud path went to `scan_parquet` whatever was under it, so a prefix of
10783    /// CSV came back "Could not read from S3. Check credentials and URL" — a false
10784    /// statement about the user's login, made about a directory datui could see the
10785    /// contents of. Polars' other scans take the same `CloudOptions` and do their own
10786    /// listing; nothing was passing them.
10787    ///
10788    /// Parquet keeps its own branch at each call site: it is the only one with hive
10789    /// partitioning, which is a Parquet-only capability in this reader, and it is the
10790    /// path every cloud dataset took before this existed.
10791    ///
10792    /// `None` when the format is not one of these, which sends the caller back to the
10793    /// Parquet scan it always made.
10794    #[cfg(feature = "cloud")]
10795    fn scan_cloud_prefix(
10796        url: &str,
10797        cloud_opts: CloudOptions,
10798        format: FileFormat,
10799        glob: bool,
10800        options: &OpenOptions,
10801    ) -> Option<Result<LazyFrame>> {
10802        // The formats the docs say a prefix reads in place. Parquet takes the caller's
10803        // own scan, and a prefix of model files is read by its headers before this.
10804        if !format.reads_bucket_prefix() {
10805            return None;
10806        }
10807        // A plain prefix is narrowed to the keys with an extension. A console's folder
10808        // marker comes back from the listing as `data` for `data/`, which Polars reads
10809        // as a file of a different kind from the rest and refuses the whole prefix.
10810        let pl_path = if url.ends_with('/') && !url.contains('*') {
10811            PlRefPath::new(format!("{url}**/*.*").as_str())
10812        } else {
10813            PlRefPath::new(url)
10814        };
10815        let scan = crate::readers::of(format).bucket_scan?;
10816        Some(scan(crate::readers::BucketIn {
10817            url,
10818            path: pl_path,
10819            cloud: cloud_opts,
10820            glob,
10821            options,
10822            format,
10823        }))
10824    }
10825
10826    /// The format a prefix or glob in a store is read as, other than Parquet: what
10827    /// the listing said, else what a glob's names end in (`*.arrow`).
10828    #[cfg(feature = "cloud")]
10829    fn cloud_glob_format(url: &str, options: &OpenOptions) -> Option<FileFormat> {
10830        options
10831            .format
10832            .or_else(|| {
10833                url.contains('*')
10834                    .then(|| FileFormat::from_path(Path::new(url)))
10835                    .flatten()
10836            })
10837            .filter(|f| *f != FileFormat::Parquet)
10838    }
10839
10840    /// The plain URL and Polars options for one object-store path, through the source
10841    /// it names or belongs to (`cloud_sources::resolve`).
10842    #[cfg(feature = "cloud")]
10843    fn resolve_cloud_url(
10844        path: &Path,
10845        cloud: &crate::config::CloudConfig,
10846    ) -> Result<(String, CloudOptions)> {
10847        let text = path.to_string_lossy();
10848        let resolved = crate::cloud_sources::resolve_for_open(&text, cloud)
10849            .map_err(|e| color_eyre::eyre::eyre!(e))?;
10850        use object_store::azure::AzureConfigKey;
10851        use polars::io::cloud::GoogleConfigKey;
10852        let gcs_agent = (
10853            GoogleConfigKey::Client(crate::user_agent::CLIENT_KEY),
10854            crate::user_agent::get(),
10855        );
10856        let options = match resolved.kind {
10857            crate::cloud_browse::ProviderKind::S3 => Self::build_s3_cloud_options(&resolved.s3),
10858            crate::cloud_browse::ProviderKind::Gcs
10859                if resolved.signing == crate::cloud_sources::Signing::Unsigned =>
10860            {
10861                CloudOptions::default()
10862                    .with_gcp([(GoogleConfigKey::SkipSignature, "true".into()), gcs_agent])
10863            }
10864            crate::cloud_browse::ProviderKind::Gcs => match &resolved.gcloud {
10865                // The token comes from `gcloud` whenever Polars asks, so a long scan
10866                // outlives the one fetched here.
10867                Some((configuration, _)) => CloudOptions::default()
10868                    .with_gcp([gcs_agent])
10869                    .with_credential_provider(Some(crate::gcloud::polars_provider(configuration))),
10870                None => match &resolved.google_credentials {
10871                    Some(file) => CloudOptions::default().with_gcp([
10872                        (
10873                            GoogleConfigKey::ApplicationCredentials,
10874                            file.to_string_lossy().into_owned(),
10875                        ),
10876                        gcs_agent,
10877                    ]),
10878                    None => CloudOptions::default().with_gcp([gcs_agent]),
10879                },
10880            },
10881            crate::cloud_browse::ProviderKind::Azure => {
10882                let (account, _, _) = source::azure_parts(&resolved.url)
10883                    .ok_or_else(|| color_eyre::eyre::eyre!("not an Azure URL"))?;
10884                let mut azure = crate::azure::polars_options(&account, &resolved.azure);
10885                azure.push((
10886                    AzureConfigKey::Client(crate::user_agent::CLIENT_KEY),
10887                    crate::user_agent::get(),
10888                ));
10889                CloudOptions::default().with_azure(azure)
10890            }
10891        };
10892        Ok((resolved.url, options))
10893    }
10894
10895    /// The URL, Polars options and store for one object-store path. `cloud` is the
10896    /// effective config the `App` keeps (see `OpenOptions::effective_cloud`).
10897    #[cfg(feature = "cloud")]
10898    fn cloud_store_for(
10899        path: &Path,
10900        cloud: &crate::config::CloudConfig,
10901        runtime: &tokio::runtime::Handle,
10902    ) -> Result<(String, CloudOptions, Arc<dyn object_store::ObjectStore>)> {
10903        let (full, cloud_opts) = Self::resolve_cloud_url(path, cloud)?;
10904        let store = Self::polars_object_store(&full, &cloud_opts, runtime)?;
10905        Ok((full, cloud_opts, store))
10906    }
10907
10908    /// One Parquet object read in place: schema and row count from its footer, in one
10909    /// tail read through one store.
10910    ///
10911    /// Asking the frame for its schema fetched the footer through Polars, and Polars
10912    /// answers `len()` on a cloud scan by reading the first row group rather than the
10913    /// footer, so the background count that followed an open downloaded row group 0 a
10914    /// second time, alongside the buffer that was showing it. The footer has both
10915    /// answers for one 256 KiB range request; the schema is handed to the scan so
10916    /// Polars does not fetch it again, and the count is known before the first frame.
10917    /// A failure is returned, not swallowed: the caller falls back to asking the frame
10918    /// and puts the reason in the debug label.
10919    #[cfg(feature = "cloud")]
10920    fn schema_state_from_cloud_object(
10921        path: &Path,
10922        options: &OpenOptions,
10923        cloud: &crate::config::CloudConfig,
10924        runtime: &tokio::runtime::Handle,
10925        report: &crate::measurements::OpenReport,
10926    ) -> Result<(DataTableState, OpenFacts)> {
10927        let (full, cloud_opts, store) = Self::cloud_store_for(path, cloud, runtime)?;
10928        let (_bucket, key) = Self::cloud_bucket_and_key(&full)?;
10929        if key.is_empty() {
10930            return Err(color_eyre::eyre::eyre!("a bucket, not an object"));
10931        }
10932        let meter = report.meter.clone();
10933        let (footer, etag) = wait_on_runtime(runtime, async move {
10934            cloud_hive::footer_of_cloud_parquet(store, &key, &meter).await
10935        })
10936        .ok_or_else(|| color_eyre::eyre::eyre!("cancelled"))??;
10937        let args = ScanArgsParquet {
10938            schema: Some(footer.schema.clone()),
10939            cloud_options: Some(cloud_opts),
10940            hive_options: polars::io::HiveOptions::default(),
10941            glob: false,
10942            ..Default::default()
10943        };
10944        let lf = LazyFrame::scan_parquet(PlRefPath::new(full.as_str()), args)?;
10945        let state =
10946            DataTableState::from_schema_and_lazyframe(footer.schema.clone(), lf, options, None)?;
10947        // The commonest cloud open, and the one the dataset index never heard about:
10948        // the prefix route records what it read, and this one read a footer too.
10949        Self::record_cloud_object_facts(report.remembered.as_ref(), &full, &footer);
10950        let column_bytes = crate::schema_union::column_bytes_per_row(&[Some(footer.clone())]);
10951        let facts = OpenFacts {
10952            remote_objects: vec![crate::local_copy::RemoteObject {
10953                url: full,
10954                size: footer.file_bytes as u64,
10955                etag,
10956            }],
10957            row_groups: vec![footer.row_group_rows],
10958            column_bytes,
10959            ..Default::default()
10960        };
10961        Ok((state, facts))
10962    }
10963
10964    /// The schema routes, cheapest first: one local footer, one cloud footer (a hive
10965    /// prefix or a single object), then asking the frame.
10966    fn schema_state_by_route(
10967        lf: LazyFrame,
10968        path: Option<&Path>,
10969        options: &OpenOptions,
10970        cloud: &crate::config::CloudConfig,
10971        runtime: &tokio::runtime::Handle,
10972        report: &crate::measurements::OpenReport,
10973    ) -> Result<(DataTableState, OpenFacts, String)> {
10974        #[cfg(not(feature = "cloud"))]
10975        let _ = (cloud, runtime);
10976
10977        // A meter per attempt, and the winner's is the open's. The routes are tried in
10978        // order and the earlier ones measure before they discover they cannot finish —
10979        // the local hive route times its walk and its footer pass, then bails five
10980        // different ways. Sharing one meter would leave those figures on a dataset some
10981        // later route built, which is a row saying no files on a dataset that has them.
10982        //
10983        // Two guards, and the test holds them together rather than either alone: the
10984        // attempts take separate meters, and both full-scan arms hand back an empty
10985        // one. A directory whose only Parquet is a writer's own bookkeeping — a
10986        // `_delta_log` checkpoint — reaches the screen through the second of those, so
10987        // `test_a_route_that_gave_up_leaves_no_figures_on_the_dataset_that_opened`
10988        // fails when both are reverted and passes when either still stands. The
10989        // per-attempt meter alone is defensive: the route guards are mutually
10990        // exclusive enough that nothing reaches a later route through the first.
10991        let attempt = |report: &crate::measurements::OpenReport| crate::measurements::OpenReport {
10992            progress: report.progress.clone(),
10993            meter: Arc::new(crate::measurements::Meter::default()),
10994            remembered: report.remembered.clone(),
10995            writes: report.writes.clone(),
10996        };
10997
10998        let local = attempt(report);
10999        if let Some((state, facts)) = Self::schema_state_from_local_hive(path, options, &local) {
11000            let facts = OpenFacts {
11001                measurements: local.meter,
11002                ..facts
11003            };
11004            return Ok((state, facts, "one-file (local)".to_string()));
11005        }
11006        // An open abandoned mid-listing is not one for the routes below to scan whole.
11007        if report.progress.is_cancelled() {
11008            return Err(color_eyre::eyre::eyre!("cancelled"));
11009        }
11010        #[cfg(feature = "cloud")]
11011        let cloud_hive_attempt = attempt(report);
11012        #[cfg(feature = "cloud")]
11013        if let Some((state, facts)) =
11014            Self::schema_state_from_cloud_hive(path, options, cloud, runtime, &cloud_hive_attempt)
11015        {
11016            let facts = OpenFacts {
11017                measurements: cloud_hive_attempt.meter,
11018                ..facts
11019            };
11020            return Ok((state, facts, "one-file (cloud)".to_string()));
11021        }
11022        #[cfg(feature = "cloud")]
11023        if let Some(p) = path.filter(|p| {
11024            source::scans_in_place(p)
11025                && !options.hive
11026                && !source::is_prefix_or_glob(&p.to_string_lossy())
11027        }) {
11028            let object = attempt(report);
11029            match Self::schema_state_from_cloud_object(p, options, cloud, runtime, &object) {
11030                Ok((state, facts)) => {
11031                    let facts = OpenFacts {
11032                        measurements: object.meter,
11033                        ..facts
11034                    };
11035                    return Ok((state, facts, "footer (cloud)".to_string()));
11036                }
11037                // Visible in the debug overlay, because the fallback costs a row group
11038                // for the count and that should not pass for the intended path.
11039                Err(e) => {
11040                    // A fresh meter, not the failed footer read's: the full scan
11041                    // measures nothing, and showing the attempt that did not work
11042                    // would describe a route the dataset did not come by.
11043                    return Self::schema_state_from_full_scan(lf, path, options).map(|state| {
11044                        (
11045                            state,
11046                            OpenFacts::default(),
11047                            format!("full scan (cloud footer: {e})"),
11048                        )
11049                    });
11050                }
11051            }
11052        }
11053        Self::schema_state_from_full_scan(lf, path, options)
11054            .map(|state| (state, OpenFacts::default(), "full scan".to_string()))
11055    }
11056
11057    /// The files of one split, when `dir` is a Hugging Face `datasets` cache: its
11058    /// `dataset_info.json` or `state.json` beside Arrow files. What was chosen and left
11059    /// out goes in `report`. Any other directory reads every file.
11060    fn hugging_face_split(
11061        dir: &Path,
11062        format: FileFormat,
11063        files: Vec<PathBuf>,
11064        options: &OpenOptions,
11065        report: &mut ReadReport,
11066    ) -> Result<Vec<PathBuf>> {
11067        let metadata = || {
11068            ["dataset_info.json", "state.json"]
11069                .iter()
11070                .any(|name| dir.join(name).is_file())
11071        };
11072        if format != FileFormat::Arrow || !metadata() {
11073            return Ok(files);
11074        }
11075        let names: Vec<&str> = files
11076            .iter()
11077            .map(|f| f.file_name().and_then(|n| n.to_str()).unwrap_or_default())
11078            .collect();
11079        let (chosen, splits) = crate::hf_splits::choose(&names, options.table.as_deref())
11080            .map_err(|e| color_eyre::eyre::eyre!("{}: {e}", dir.display()))?;
11081        report.splits = Some(Arc::new(splits));
11082        Ok(chosen.into_iter().map(|i| files[i].clone()).collect())
11083    }
11084
11085    /// Why `--table` was refused for `path`, a file of `format`, which holds one table.
11086    fn one_table(path: Option<&Path>, format: Option<FileFormat>) -> color_eyre::Report {
11087        match path {
11088            Some(path) => crate::error_display::FileError::new(path, cli::one_table(format)).into(),
11089            None => color_eyre::eyre::eyre!(cli::one_table(format)),
11090        }
11091    }
11092
11093    /// A scan of `url` in an object store that Polars refused, with what to check.
11094    #[cfg(feature = "cloud")]
11095    fn cloud_scan_failed(url: &str, e: &polars::prelude::PolarsError) -> color_eyre::Report {
11096        let said = crate::error_display::user_message_from_polars(e);
11097        let (first, rest) = said.split_once('\n').unwrap_or((&said, ""));
11098        let first = first.trim_end().trim_end_matches('.');
11099        let what = format!("could not read it: {first}. Check the credentials and the URL.");
11100        let what = match rest {
11101            "" => what,
11102            rest => format!("{what}\n{rest}"),
11103        };
11104        crate::error_display::FileError::new(Path::new(url), what).into()
11105    }
11106
11107    /// The inputs of an Arrow read as one table, in order: each IPC file scanned where
11108    /// it is, in a bucket or on disk, and each run of streams as its rows of
11109    /// `converted`, the IPC file they were converted to. Stacked as the files of a
11110    /// directory are ([`DataTableState::union_of_files`]).
11111    fn scan_arrow_parts(
11112        cloud: &crate::config::CloudConfig,
11113        converted: Option<&PathBuf>,
11114        parts: &[crate::ipc_stream::Part],
11115    ) -> Result<LazyFrame> {
11116        use crate::ipc_stream::Part;
11117        #[cfg(not(feature = "cloud"))]
11118        let _ = cloud;
11119        let scan = |path: &Path| -> Result<LazyFrame> {
11120            #[cfg(feature = "cloud")]
11121            if source::is_remote_url(path) {
11122                let (url, cloud_options) = Self::resolve_cloud_url(path, cloud)?;
11123                let args = polars::prelude::UnifiedScanArgs {
11124                    cloud_options: Some(cloud_options),
11125                    ..Default::default()
11126                };
11127                return Ok(LazyFrame::scan_ipc(
11128                    PlRefPath::new(url.as_str()),
11129                    Default::default(),
11130                    args,
11131                )?);
11132            }
11133            // A converted stream sits in a temp directory the user names, `[` and all,
11134            // and a file read in place may be called `d[1].arrow` (#632).
11135            let args = polars::prelude::UnifiedScanArgs {
11136                glob: source::expands_as_glob(path),
11137                ..Default::default()
11138            };
11139            Ok(LazyFrame::scan_ipc(
11140                polars::prelude::PlRefPath::try_from_path(path)?,
11141                Default::default(),
11142                args,
11143            )?)
11144        };
11145        let streams = |offset: u64, rows: u64| -> Result<LazyFrame> {
11146            let file = converted
11147                .ok_or_else(|| color_eyre::eyre::eyre!("No converted Arrow file to read."))?;
11148            let lf = scan(file)?;
11149            // The whole file needs no slice, which would hide its row count.
11150            let whole = offset == 0
11151                && parts
11152                    .iter()
11153                    .all(|part| matches!(part, Part::Converted { .. }));
11154            Ok(if whole {
11155                lf
11156            } else {
11157                lf.slice(offset as i64, rows as polars::prelude::IdxSize)
11158            })
11159        };
11160        let mut frames = Vec::new();
11161        let mut run: Option<(u64, u64)> = None;
11162        for part in parts {
11163            match part {
11164                Part::Converted { offset, rows, .. } => {
11165                    run = Some(match run {
11166                        Some((start, n)) if start + n == *offset => (start, n + rows),
11167                        Some((start, n)) => {
11168                            frames.push(streams(start, n)?);
11169                            (*offset, *rows)
11170                        }
11171                        None => (*offset, *rows),
11172                    });
11173                }
11174                Part::InPlace(path) => {
11175                    if let Some((start, n)) = run.take() {
11176                        frames.push(streams(start, n)?);
11177                    }
11178                    frames.push(scan(path)?);
11179                }
11180            }
11181        }
11182        if let Some((start, n)) = run {
11183            frames.push(streams(start, n)?);
11184        }
11185        match frames.len() {
11186            0 => Err(color_eyre::eyre::eyre!("No Arrow files to read.")),
11187            1 => Ok(frames.remove(0)),
11188            _ => Ok(polars::prelude::concat(
11189                frames.as_slice(),
11190                DataTableState::union_of_files(),
11191            )?),
11192        }
11193    }
11194
11195    /// One split of a `save_to_disk` DatasetDict, `dir`, whose `dataset_dict.json` names
11196    /// `splits`: the subdirectory `--table` names, else the first offered, read as any
11197    /// directory is. The others are listed, as a cache directory's are.
11198    fn dataset_dict_split(
11199        dir: &Path,
11200        splits: &[String],
11201        options: &OpenOptions,
11202        report: &mut ReadReport,
11203        formats: &crate::formats::Registry,
11204    ) -> Result<Scan> {
11205        let listed: Vec<&str> = splits.iter().map(String::as_str).collect();
11206        let mut picked = crate::hf_splits::pick(&listed, options.table.as_deref())
11207            .map_err(|e| color_eyre::eyre::eyre!("{}: {e}", dir.display()))?;
11208        let split = dir.join(picked.split.as_deref().unwrap_or_default());
11209        let inner = OpenOptions {
11210            table: None,
11211            splits: None,
11212            ..options.clone()
11213        };
11214        let scan = Self::build_local_lazyframe(&[split], &inner, report, formats)?;
11215        // The split's own directory names no splits; its `map()` files are still counted.
11216        picked.caches = report.splits.as_ref().map_or(0, |inner| inner.caches);
11217        report.splits = Some(Arc::new(picked));
11218        Ok(scan)
11219    }
11220
11221    /// Build the LazyFrame for `paths`.
11222    ///
11223    /// Takes the cloud config by reference rather than reading `self`, so the same
11224    /// code can run on a background thread — scanning is where the wall-clock time
11225    /// goes for CSV (schema inference) and for hive directories with many files.
11226    /// Whether the files about to be read as one table do not all carry the same
11227    /// columns, for the note that says so.
11228    ///
11229    /// Only for the formats with no footer. A Parquet dataset's footers are read anyway
11230    /// and produce the exact version of this — which columns, in how many files, and
11231    /// where — so a second, vaguer note above those would be noise.
11232    ///
11233    /// A spread of the files rather than all of them, the same three
11234    /// [`crate::schema_union::sample_files`] reads for the label, and for the same
11235    /// reason: this runs on the way into a read the user is waiting for.
11236    fn files_disagree(
11237        files: &[PathBuf],
11238        options: &OpenOptions,
11239        found: FileFormat,
11240    ) -> crate::schema_union::Disagreement {
11241        // The format the read will use, not the one the names suggested: an explicit
11242        // `--format` outranks both, and judging a directory with a reader the open will
11243        // not use is a note about a read that never happened.
11244        let format = options.format.unwrap_or(found);
11245        if format == FileFormat::Parquet {
11246            return Default::default();
11247        }
11248        // Null values are the one setting the sample cannot mirror: `--null`
11249        // takes `COL=VAL` forms the reader resolves against the file it is opening, and
11250        // a sample that guessed would report a widening the table never did. They are
11251        // unset unless the user names them, so this stands down where it must and runs
11252        // everywhere else.
11253        if options.null_values.is_some() {
11254            return Default::default();
11255        }
11256        crate::schema_union::sample_files(files, format, &Self::read_as(options)).disagreement()
11257    }
11258
11259    /// The reader settings a sample has to copy to describe what the open will do.
11260    ///
11261    /// Taken from the options the open is actually being made with, not guessed at and
11262    /// then bailed out of: `from_args_and_config` fills in `infer_schema_length` and
11263    /// `parse_strings` on every run with no flags at all, so a predicate over "did the
11264    /// user set anything" is true every time. That shipped once, and the notes about
11265    /// how a directory had been stacked never appeared outside the tests.
11266    fn read_as(options: &OpenOptions) -> crate::schema_union::ReadAs {
11267        crate::schema_union::ReadAs {
11268            delimiter: options.delimiter,
11269            has_header: options.has_header,
11270            skip_rows: options.skip_rows,
11271            skip_lines: options.skip_lines,
11272            infer_schema_length: options.infer_schema_length,
11273            ignore_errors: options.ignore_errors,
11274            try_parse_dates: options.csv_try_parse_dates(),
11275            comment_char: options.comment_char.clone(),
11276            header_rows: options.header_rows.clone(),
11277            header_join: options.header_join.clone(),
11278        }
11279    }
11280
11281    /// A format spec reads a local file, or the downloaded copy of one remote object
11282    /// (`loading::remote_download`). What reaches here remote is a prefix or a glob,
11283    /// which would otherwise be scanned in place without the spec and say nothing.
11284    fn refuse_spec_in_place(path: &Path, options: &OpenOptions) -> Result<()> {
11285        if source::is_remote_url(path)
11286            && (options.spec_file.is_some() || options.spec_name.is_some())
11287        {
11288            return Err(crate::error_display::FileError::new(
11289                path,
11290                "a format spec reads one remote object at a time, not a prefix or a glob; name the object",
11291            )
11292            .into());
11293        }
11294        Ok(())
11295    }
11296
11297    /// `found` is what the read has to say about itself, for the caller to put in the
11298    /// dataset's notes: which data files it passed over, and whether the files it did
11299    /// read carry the same columns. Written here rather than worked out by the caller
11300    /// because this is the pass that decides, and a second opinion formed from a second
11301    /// directory read is a second answer waiting to disagree.
11302    fn build_lazyframe_from_paths_with(
11303        cloud: &crate::config::CloudConfig,
11304        paths: &[PathBuf],
11305        options: &OpenOptions,
11306        report: &mut ReadReport,
11307        formats: &crate::formats::Registry,
11308    ) -> Result<Scan> {
11309        // Arrow streams converted, or a bucket's Arrow listed: the load says where
11310        // each input's rows are.
11311        if let Some(parts) = &options.arrow_parts {
11312            if options.table.is_some() && options.splits.is_none() {
11313                // Named by an input the user knows, never the converted copy.
11314                let named = parts.first().map(|part| match part {
11315                    crate::ipc_stream::Part::InPlace(path) => path.as_path(),
11316                    crate::ipc_stream::Part::Converted { source, .. } => source.as_path(),
11317                });
11318                return Err(Self::one_table(named, Some(FileFormat::Arrow)));
11319            }
11320            return Self::scan_arrow_parts(cloud, paths.first(), parts).map(Scan::from);
11321        }
11322        // Only the cloud readers below take the settings.
11323        #[cfg(not(feature = "cloud"))]
11324        let _ = cloud;
11325        let path = &paths[0];
11326        Self::refuse_spec_in_place(path, options)?;
11327        match source::input_source(path) {
11328            source::InputSource::Http(_url) => {
11329                #[cfg(feature = "http")]
11330                {
11331                    return Err(color_eyre::eyre::eyre!(
11332                        "HTTP/HTTPS load is handled in the event loop; this path should not be reached."
11333                    ));
11334                }
11335                #[cfg(not(feature = "http"))]
11336                {
11337                    return Err(color_eyre::eyre::eyre!(
11338                        "HTTP/HTTPS URLs are not supported in this build. Rebuild with default features."
11339                    ));
11340                }
11341            }
11342            source::InputSource::S3(url) => {
11343                #[cfg(feature = "cloud")]
11344                {
11345                    let (full, cloud_opts) =
11346                        Self::resolve_cloud_url(Path::new(&format!("s3://{url}")), cloud)?;
11347                    let is_glob = source::is_prefix_or_glob(&full);
11348                    // The reader the prefix's own format calls for, when the listing
11349                    // said what that is. Only Parquet falls through to the scan below.
11350                    if let Some(format) = Self::cloud_glob_format(&full, options)
11351                        && let Some(lf) = Self::scan_cloud_prefix(
11352                            &full,
11353                            cloud_opts.clone(),
11354                            format,
11355                            is_glob,
11356                            options,
11357                        )
11358                    {
11359                        return lf.map(Scan::from);
11360                    }
11361                    let pl_path = PlRefPath::new(full.as_str());
11362                    let hive_options = if is_glob {
11363                        polars::io::HiveOptions::new_enabled()
11364                    } else {
11365                        polars::io::HiveOptions::default()
11366                    };
11367                    let args = ScanArgsParquet {
11368                        cloud_options: Some(cloud_opts),
11369                        hive_options,
11370                        glob: is_glob,
11371                        ..Default::default()
11372                    };
11373                    let lf = LazyFrame::scan_parquet(pl_path, args)
11374                        .map_err(|e| Self::cloud_scan_failed(&full, &e))?;
11375                    // The frame alone. Building a state here would ask Polars for the
11376                    // schema, which lists every file under a prefix, and the schema
11377                    // phase that follows lists them once more for itself.
11378                    return Ok(lf.into());
11379                }
11380                #[cfg(not(feature = "cloud"))]
11381                {
11382                    let _ = url;
11383                    return Err(color_eyre::eyre::eyre!(
11384                        "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)."
11385                    ));
11386                }
11387            }
11388            source::InputSource::Gcs(url) => {
11389                #[cfg(feature = "cloud")]
11390                {
11391                    let (full, cloud_opts) =
11392                        Self::resolve_cloud_url(Path::new(&format!("gs://{url}")), cloud)?;
11393                    let is_glob = source::is_prefix_or_glob(&full);
11394                    // The reader the prefix's own format calls for, when the listing
11395                    // said what that is. Only Parquet falls through to the scan below.
11396                    if let Some(format) = Self::cloud_glob_format(&full, options)
11397                        && let Some(lf) = Self::scan_cloud_prefix(
11398                            &full,
11399                            cloud_opts.clone(),
11400                            format,
11401                            is_glob,
11402                            options,
11403                        )
11404                    {
11405                        return lf.map(Scan::from);
11406                    }
11407                    let pl_path = PlRefPath::new(full.as_str());
11408                    let hive_options = if is_glob {
11409                        polars::io::HiveOptions::new_enabled()
11410                    } else {
11411                        polars::io::HiveOptions::default()
11412                    };
11413                    let args = ScanArgsParquet {
11414                        cloud_options: Some(cloud_opts),
11415                        hive_options,
11416                        glob: is_glob,
11417                        ..Default::default()
11418                    };
11419                    let lf = LazyFrame::scan_parquet(pl_path, args)
11420                        .map_err(|e| Self::cloud_scan_failed(&full, &e))?;
11421                    return Ok(lf.into());
11422                }
11423                #[cfg(not(feature = "cloud"))]
11424                {
11425                    let _ = url;
11426                    return Err(color_eyre::eyre::eyre!(
11427                        "GCS (gs://) is not supported in this build. Rebuild with default features."
11428                    ));
11429                }
11430            }
11431            source::InputSource::Azure(url) => {
11432                #[cfg(feature = "cloud")]
11433                {
11434                    let (full, cloud_opts) = Self::resolve_cloud_url(Path::new(&url), cloud)?;
11435                    let is_glob = source::is_prefix_or_glob(&full);
11436                    // The reader the prefix's own format calls for, when the listing
11437                    // said what that is. Only Parquet falls through to the scan below.
11438                    if let Some(format) = Self::cloud_glob_format(&full, options)
11439                        && let Some(lf) = Self::scan_cloud_prefix(
11440                            &full,
11441                            cloud_opts.clone(),
11442                            format,
11443                            is_glob,
11444                            options,
11445                        )
11446                    {
11447                        return lf.map(Scan::from);
11448                    }
11449                    let args = ScanArgsParquet {
11450                        cloud_options: Some(cloud_opts),
11451                        hive_options: if is_glob {
11452                            polars::io::HiveOptions::new_enabled()
11453                        } else {
11454                            polars::io::HiveOptions::default()
11455                        },
11456                        glob: is_glob,
11457                        ..Default::default()
11458                    };
11459                    let lf = LazyFrame::scan_parquet(PlRefPath::new(full.as_str()), args)
11460                        .map_err(|e| Self::cloud_scan_failed(&full, &e))?;
11461                    return Ok(lf.into());
11462                }
11463                #[cfg(not(feature = "cloud"))]
11464                {
11465                    let _ = url;
11466                    return Err(color_eyre::eyre::eyre!(
11467                        "Azure is not supported in this build. Rebuild with default features."
11468                    ));
11469                }
11470            }
11471            source::InputSource::Local(_) => {}
11472        }
11473        Self::build_local_lazyframe(paths, options, report, formats)
11474    }
11475
11476    /// The files a directory holds, read as `found`: through the delimited spec the
11477    /// first of them matches, when one does, else as the format says.
11478    fn read_directory_files(
11479        files: &[PathBuf],
11480        options: &OpenOptions,
11481        found: FileFormat,
11482        report: &mut ReadReport,
11483        formats: &crate::formats::Registry,
11484    ) -> Result<Scan> {
11485        if options.delimited.is_none()
11486            && options.format.is_none()
11487            && found.separator().is_some()
11488            && let Some(first) = files.iter().find(|f| !crate::nul_tail::holds_nothing(f))
11489            && let Some(choice) = Self::delimited_spec_of(first, options, formats)?
11490        {
11491            let nested = OpenOptions {
11492                hive: false,
11493                format: Some(found),
11494                splits: report.splits.clone(),
11495                ..options.clone()
11496            };
11497            return Self::read_with_delimited_spec(files, &nested, report, formats, choice);
11498        }
11499        // A spec's read says how its files differ itself, from their own header lines.
11500        report.files_disagree = Self::files_disagree(files, options, found);
11501        let nested = OpenOptions {
11502            hive: false,
11503            format: Some(options.format.unwrap_or(found)),
11504            splits: report.splits.clone(),
11505            ..options.clone()
11506        };
11507        Self::build_local_lazyframe(files, &nested, report, formats)
11508    }
11509
11510    /// The delimited spec whose glob or magic `file` matches, if one does.
11511    fn delimited_spec_of(
11512        file: &Path,
11513        options: &OpenOptions,
11514        formats: &crate::formats::Registry,
11515    ) -> Result<Option<crate::formats::Choice>> {
11516        let asked = crate::formats::Asked {
11517            compression: options.compression,
11518            text_only: true,
11519            ..Default::default()
11520        };
11521        match crate::formats::route(file, &asked, formats)
11522            .map_err(|e| color_eyre::eyre::eyre!(e))?
11523        {
11524            crate::formats::Route::Delimited(choice) => Ok(Some(choice)),
11525            _ => Ok(None),
11526        }
11527    }
11528
11529    /// `paths` read with the CSV reader in the dialect of the delimited spec `choice`
11530    /// holds.
11531    fn read_with_delimited_spec(
11532        paths: &[PathBuf],
11533        options: &OpenOptions,
11534        report: &mut ReadReport,
11535        formats: &crate::formats::Registry,
11536        choice: crate::formats::Choice,
11537    ) -> Result<Scan> {
11538        let mut nested = options.clone();
11539        let Some(delimited) = choice.spec.delimited.clone() else {
11540            return Err(color_eyre::eyre::eyre!(
11541                "{} is not a delimited spec",
11542                choice.spec.name
11543            ));
11544        };
11545        delimited.apply(&mut nested);
11546        nested.delimited = Some(Arc::new(crate::delimited_spec::DelimitedRead::chosen(
11547            choice.spec,
11548            choice.by,
11549            choice.also,
11550        )));
11551        Self::build_local_lazyframe(paths, &nested, report, formats)
11552    }
11553
11554    /// The local half of `build_lazyframe_from_paths_with`. A directory resolves to
11555    /// local files, so the recursion stays here and needs no cloud settings.
11556    fn build_local_lazyframe(
11557        paths: &[PathBuf],
11558        options: &OpenOptions,
11559        report: &mut ReadReport,
11560        formats: &crate::formats::Registry,
11561    ) -> Result<Scan> {
11562        let path = &paths[0];
11563
11564        // `--hex`: the file's bytes, whatever it holds.
11565        if options.hex
11566            && let [one] = paths
11567            && one.is_file()
11568        {
11569            return Ok(Scan::Hex {
11570                file: one.clone(),
11571                asked: true,
11572            });
11573        }
11574
11575        // A glob of local files the first of which a delimited spec reads: read through
11576        // the spec, file by file, as a directory of them is. Polars' own scan of the
11577        // glob would read the spec's header lines as data.
11578        if let [pattern] = paths
11579            && !options.hive
11580            && options.delimited.is_none()
11581            && options.format.is_none()
11582            && source::expands_as_glob(pattern)
11583        {
11584            let files = crate::local_glob::expand(pattern);
11585            if let Some(first) = files.iter().find(|f| !crate::nul_tail::holds_nothing(f))
11586                && let Some(choice) = Self::delimited_spec_of(first, options, formats)?
11587            {
11588                let format = FileFormat::from_path(first).filter(|f| f.separator().is_some());
11589                let nested = OpenOptions {
11590                    format: format.or(Some(FileFormat::Csv)),
11591                    ..options.clone()
11592                };
11593                return Self::read_with_delimited_spec(&files, &nested, report, formats, choice);
11594            }
11595        }
11596
11597        // A format spec: one asked for, or one whose glob or magic the path matches. A
11598        // path whose name or bytes already say what it is opens as it always has, but
11599        // for a delimited spec's text. Several files are matched by the first, and
11600        // only to a delimited spec.
11601        if !options.hive && options.delimited.is_none() {
11602            let asked = crate::formats::Asked {
11603                spec_file: options.spec_file.clone(),
11604                spec_name: options.spec_name.clone(),
11605                variant: options.table.clone(),
11606                spec: options.spec_fetched.clone(),
11607                builtin: options.format.is_some(),
11608                compression: options.compression,
11609                text_only: paths.len() > 1,
11610            };
11611            match crate::formats::route(path, &asked, formats)
11612                .map_err(|e| color_eyre::eyre::eyre!(e))?
11613            {
11614                crate::formats::Route::Elsewhere => {}
11615                crate::formats::Route::Delimited(choice) => {
11616                    return Self::read_with_delimited_spec(paths, options, report, formats, choice);
11617                }
11618                crate::formats::Route::Read(read) => {
11619                    let lf = Arc::clone(&read.records).into_lazy()?;
11620                    report.format_read = Some(Arc::new(*read));
11621                    return Ok(lf.into());
11622                }
11623                crate::formats::Route::Decompress(choice) => {
11624                    return Ok(Scan::DecompressSpec {
11625                        file: path.clone(),
11626                        choice,
11627                    });
11628                }
11629            }
11630        } else if options.hive && (options.spec_file.is_some() || options.spec_name.is_some()) {
11631            return Err(color_eyre::eyre::eyre!(
11632                "a format spec reads one file, or one directory of column files"
11633            ));
11634        }
11635
11636        // The header lines of a delimited spec's first file: its units and metadata.
11637        if let Some(read) = &options.delimited
11638            && report.delimited.is_none()
11639            && path.is_file()
11640        {
11641            report.delimited = Some(Arc::new(crate::delimited_spec::read_facts(
11642                read, paths, options,
11643            )?));
11644        }
11645
11646        // One path that is a directory, whether or not `--hive` said so: naming a
11647        // directory is the request to read it, and the dispatch below is what picks the
11648        // reader for what it holds. Behind `options.hive` alone, every route that
11649        // reached here with a directory and without the flag fell through to the
11650        // Parquet scan and answered `Unsupported file type`.
11651        if paths.len() == 1 && (options.hive || path.is_dir()) {
11652            // A file is a file whatever its name holds: `a*b.parquet` is not a glob.
11653            let is_single_file = path.is_file();
11654            if !is_single_file {
11655                // What the directory holds picks the reader. A directory used to go
11656                // straight to the Parquet scan whatever was in it, so a directory of
11657                // `.json.gz` was opened by seeking each file's last four bytes for a
11658                // `PAR1` that was never going to be there — the files were fine, the
11659                // reader was never asked to be the right one.
11660                if path.is_dir()
11661                    && let Some(splits) = crate::hf_splits::dataset_dict(path)
11662                {
11663                    return Self::dataset_dict_split(path, &splits, options, report, formats);
11664                }
11665                if path.is_dir() {
11666                    match crate::discover::directory_format(path) {
11667                        // Flat and Parquet: the scan below is already right for it.
11668                        crate::discover::DirectoryFormat::One(FileFormat::Parquet, _) => {}
11669                        // Partitions, or an empty directory. The files are a level down
11670                        // under `key=value` and only the hive scan walks a tree — but
11671                        // hive partitioning is a Parquet-only capability in the reader
11672                        // datui uses (`HiveOptions::new_disabled()` is hard-coded for
11673                        // CSV and NDJSON), so partitions of anything else cannot be
11674                        // read as one table here. Saying which files they are beats
11675                        // Parquet's complaint that they do not end with `PAR1`.
11676                        crate::discover::DirectoryFormat::Deeper => {
11677                            if let crate::discover::DirectoryFormat::One(found, files) =
11678                                crate::discover::hive_leaf_format(path)
11679                                && found != FileFormat::Parquet
11680                            {
11681                                // The extension rather than the format's own name: it
11682                                // is what is on the files the user can see.
11683                                let named = files
11684                                    .first()
11685                                    .and_then(|f| crate::discover::data_extension(f))
11686                                    .unwrap_or_else(|| format!("{found:?}").to_lowercase());
11687                                return Err(color_eyre::eyre::eyre!(
11688                                    "{} is partitioned into key=value directories of .{} \
11689                                     files. datui reads hive partitioning for Parquet \
11690                                     only — open one partition instead.",
11691                                    path.display(),
11692                                    named
11693                                ));
11694                            }
11695                        }
11696                        crate::discover::DirectoryFormat::One(found, files) => {
11697                            // Read as the files themselves, through the same readers a
11698                            // list of files typed on the command line goes through. An
11699                            // explicit `--format` is the user's own answer and outranks
11700                            // what the names say.
11701                            let format = options.format.unwrap_or(found);
11702                            let files =
11703                                Self::hugging_face_split(path, format, files, options, report)?;
11704                            return Self::read_directory_files(
11705                                &files, options, found, report, formats,
11706                            );
11707                        }
11708                        crate::discover::DirectoryFormat::Mixed {
11709                            format: found,
11710                            files,
11711                            passed_over,
11712                        } => {
11713                            // The commonest format is the table. A directory of a
11714                            // thousand CSVs and one stray JSON is a directory of CSVs,
11715                            // and refusing the whole of it over the stray was datui
11716                            // deciding that a directory it could read was not worth
11717                            // reading.
11718                            let format = options.format.unwrap_or(found);
11719                            let files =
11720                                Self::hugging_face_split(path, format, files, options, report)?;
11721                            let lf = Self::read_directory_files(
11722                                &files, options, found, report, formats,
11723                            )?;
11724                            // After the call, which reads a flat directory of one format
11725                            // and leaves nothing out of its own. A model's config and
11726                            // tokenizer JSON are not data the read passed over, and the
11727                            // weights are not the commonest format there, so neither is
11728                            // said.
11729                            if !matches!(found, FileFormat::Safetensors | FileFormat::Gguf) {
11730                                report.left_out = passed_over;
11731                            }
11732                            return Ok(lf);
11733                        }
11734                    }
11735                }
11736                let use_parquet_hive =
11737                    path.is_dir() || path.as_os_str().to_string_lossy().contains(".parquet");
11738                if use_parquet_hive {
11739                    // Only build the LazyFrame here; schema and partition discovery are the
11740                    // schema phase's ("Reading schema").
11741                    return DataTableState::scan_parquet_hive(path).map(Scan::from);
11742                }
11743                return Err(color_eyre::eyre::eyre!(
11744                    "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
11745                ));
11746            }
11747        }
11748
11749        // A file with no extension may still be Parquet: a part file in a directory named
11750        // `.parquet`. A regular file is only read when nothing else settled it. A name
11751        // that says text (`.log`, `.txt`) is read as lines unless its bytes say a format:
11752        // candump writes `.log`.
11753        let compressed = options
11754            .compression
11755            .or_else(|| CompressionFormat::from_extension(path))
11756            .is_some();
11757        // Under a compression suffix, the name before it says delimited text or lines
11758        // (`x.tsv.gz`, `app.log.gz`).
11759        let named = FileFormat::from_path(path).or_else(|| {
11760            compressed
11761                .then(|| FileFormat::from_path(Path::new(path.file_stem()?)))
11762                .flatten()
11763                .filter(|f| f.decompressed_once())
11764        });
11765        let mut effective_format = options
11766            .format
11767            // A name that says a format another refines is asked its bytes for it:
11768            // journal JSON in a `.json` file.
11769            .or_else(|| {
11770                named.filter(|f| !f.is_lines()).map(|f| {
11771                    (!compressed)
11772                        .then(|| crate::readers::refined(path, f))
11773                        .flatten()
11774                        .unwrap_or(f)
11775                })
11776            })
11777            .or_else(|| {
11778                (path.extension().is_none()
11779                    && crate::discover::is_parquet_key(&path.to_string_lossy()))
11780                .then_some(FileFormat::Parquet)
11781            })
11782            // Any other file whose name says no format, by its first bytes: each
11783            // format's signature says where it is believed (`crate::readers`).
11784            .or_else(|| crate::readers::sniff_open(path, options.compression))
11785            .or(named);
11786        // Text no signature claims: JSON, CSV or TSV on evidence, lines otherwise. Bytes
11787        // that are not text are shown as they are.
11788        if effective_format.is_none()
11789            && let [file] = paths
11790            && file.is_file()
11791        {
11792            effective_format = crate::lines::guess_file(file, options.compression)
11793                .map(|f| crate::lines::as_asked(f, options));
11794            report.guessed = effective_format.is_some();
11795        }
11796        report.format = effective_format;
11797
11798        // Refused rather than ignored: a file of one table opened with `--table` would
11799        // otherwise look like the table asked for.
11800        if options.table.is_some()
11801            && !effective_format.is_some_and(FileFormat::takes_table)
11802            && options.splits.is_none()
11803        {
11804            return Err(Self::one_table(Some(path), effective_format));
11805        }
11806
11807        // One compressed CSV, TSV, PSV or text file, as a directory of one resolves to:
11808        // the load decompresses it (`Step::Decompress`) into a copy the dataset holds.
11809        // Read here, the copy went with the state dropped below and the frame scanned
11810        // nothing.
11811        if let [file] = paths
11812            && compressed
11813            && let Some(format) = effective_format.filter(|f| f.decompressed_once())
11814        {
11815            return Ok(Scan::Decompress {
11816                file: file.clone(),
11817                format,
11818            });
11819        }
11820
11821        let Some(format) = effective_format else {
11822            if !path.exists() {
11823                return Err(std::io::Error::new(
11824                    std::io::ErrorKind::NotFound,
11825                    format!("File not found: {}", path.display()),
11826                )
11827                .into());
11828            }
11829            // A local file nothing reads is shown as its bytes (#588).
11830            if paths.len() == 1 && path.is_file() {
11831                return Ok(Scan::Hex {
11832                    file: path.clone(),
11833                    asked: false,
11834                });
11835            }
11836            return Err(color_eyre::eyre::eyre!(match paths.len() {
11837                1 => UNSUPPORTED.to_string(),
11838                _ => crate::readers::many_files_refused(),
11839            }));
11840        };
11841        // The home screen asks `reads_many_files` before it offers a directory as one
11842        // dataset, and this is the same question, so it cannot offer one this refuses.
11843        if paths.len() > 1 && !format.reads_many_files() {
11844            if !path.exists() {
11845                return Err(std::io::Error::new(
11846                    std::io::ErrorKind::NotFound,
11847                    format!("File not found: {}", path.display()),
11848                )
11849                .into());
11850            }
11851            return Err(color_eyre::eyre::eyre!(crate::readers::many_files_refused()));
11852        }
11853        let guessed;
11854        let options = if report.guessed {
11855            guessed = OpenOptions {
11856                format_guessed: true,
11857                ..options.clone()
11858            };
11859            &guessed
11860        } else {
11861            options
11862        };
11863        crate::readers::scan(crate::readers::ScanIn {
11864            format,
11865            paths,
11866            options,
11867            report,
11868            formats,
11869        })
11870    }
11871
11872    /// Whether the help overlay is on screen.
11873    pub fn help_visible(&self) -> bool {
11874        self.help.is_open()
11875    }
11876
11877    /// The screen the help overlay shows the keys of, while it is up.
11878    pub fn help_context(&self) -> Option<datui_cli::keys::Context> {
11879        self.help.context()
11880    }
11881
11882    /// Open the views list for the dataset on screen, scored against it.
11883    fn open_view_list(&mut self) {
11884        if self.view_dataset().is_none() {
11885            return;
11886        }
11887        self.view_modal.table_state.select(Some(0));
11888        self.refresh_view_list();
11889        self.view_modal.active = true;
11890        self.view_modal.mode = ViewModalMode::List;
11891    }
11892
11893    /// Rebuild the list's rows from the store, scored and annotated against
11894    /// the open dataset; the selection stays near where it was.
11895    fn refresh_view_list(&mut self) {
11896        let (Some(state), Some(dataset)) = (&self.data_table_state, self.view_dataset()) else {
11897            return;
11898        };
11899        let rows: Vec<ViewRow> = self
11900            .view_manager
11901            .find_relevant_views(dataset, state.source_schema())
11902            .into_iter()
11903            .map(|(view, score)| {
11904                let reason = view::match_reason(&view, dataset, state.source_schema());
11905                ViewRow {
11906                    view,
11907                    score,
11908                    reason,
11909                }
11910            })
11911            .collect();
11912        self.view_modal.broken_views = self.view_manager.broken_views.clone();
11913        let selected = self.view_modal.table_state.selected().unwrap_or(0);
11914        self.view_modal.table_state.select(if rows.is_empty() {
11915            None
11916        } else {
11917            Some(selected.min(rows.len() - 1))
11918        });
11919        self.view_modal.rows = rows;
11920    }
11921
11922    /// Open the save-view form prefilled from the open dataset: a name the
11923    /// user will recognize, this file's paths and patterns as criteria, and
11924    /// schema match on — the criterion that carries the view to the next
11925    /// table shaped like this one.
11926    fn open_save_view_form(&mut self) {
11927        self.view_modal
11928            .enter_create_mode(self.history_limit, &self.theme);
11929
11930        let query = self.data_table_state.as_ref().and_then(|state| {
11931            let (query, sql_query, fuzzy_query) = active_query_settings(
11932                state.get_active_query(),
11933                state.get_active_sql_query(),
11934                state.get_active_fuzzy_query(),
11935            );
11936            sql_query.or(fuzzy_query).or(query)
11937        });
11938        self.view_modal.name_input.suggest(
11939            self.view_manager
11940                .suggest_name(self.path.as_deref(), query.as_deref()),
11941        );
11942
11943        // Data piped in has no file to pin; its columns are what match it.
11944        if let Some(path) = self.path.as_ref().filter(|_| !self.reads_stdin()) {
11945            // Pin this file: its absolute path or URL, its path relative to the
11946            // working directory when it is local and under it, and glob suggestions.
11947            let absolute_path = view::exact_location(path);
11948            self.view_modal
11949                .exact_path_input
11950                .suggest(absolute_path.to_string_lossy());
11951            if let Some(relative) = view::relative_location(path) {
11952                self.view_modal.relative_path_input.suggest(relative);
11953            }
11954
11955            // Suggest a path pattern from the absolute path: the parent of a
11956            // bare relative name is "", and ""/*.parquet is a pattern that
11957            // matches every parquet file anywhere, forever. The separator is the
11958            // path's own, or a Windows path never fits its pattern.
11959            if let Some(parent) = absolute_path.parent()
11960                && let Some(parent_str) = parent.to_str()
11961                && !parent_str.is_empty()
11962                && let Some(ext) = absolute_path.extension()
11963            {
11964                let separator = if crate::source::is_remote_url(path) {
11965                    '/'
11966                } else {
11967                    std::path::MAIN_SEPARATOR
11968                };
11969                self.view_modal.path_pattern_input.suggest(format!(
11970                    "{}{separator}*.{}",
11971                    parent_str.trim_end_matches(separator),
11972                    ext.to_string_lossy()
11973                ));
11974            }
11975
11976            // Suggest a filename pattern with digit runs wildcarded, so
11977            // sales_2024.csv offers itself to sales_2025.csv.
11978            if let Some(filename) = path.file_name()
11979                && let Some(filename_str) = filename.to_str()
11980            {
11981                use regex::Regex;
11982                let pattern = match Regex::new(r"\d+") {
11983                    Ok(re) => re.replace_all(filename_str, "*").to_string(),
11984                    Err(_) => filename_str.to_string(),
11985                };
11986                self.view_modal.filename_pattern_input.suggest(pattern);
11987            }
11988        }
11989
11990        self.view_modal.table = self.view_table().map(str::to_string);
11991
11992        // Schema match starts on: "apply this to a similar table" is the
11993        // reason views exist, and the columns are the only criterion that
11994        // says similar.
11995        if let Some(ref state) = self.data_table_state
11996            && !state.source_schema().is_empty()
11997        {
11998            self.view_modal.schema_match_enabled = true;
11999        }
12000    }
12001
12002    /// Validate and persist the form: a new view, or the edited one. The
12003    /// settings are rebuilt from the table's applied state either way. A
12004    /// failed save keeps the form open.
12005    fn save_view_form(&mut self) {
12006        self.view_modal.name_error = None;
12007        let name = self.view_modal.name_input.value().trim().to_string();
12008        if name.is_empty() {
12009            self.view_modal.name_error = Some("name is required".to_string());
12010            self.view_modal.form_focus = FormFocus::Name;
12011            return;
12012        }
12013        let renaming_to_taken = match &self.view_modal.editing_view_id {
12014            None => self.view_manager.view_exists(&name),
12015            Some(id) => self
12016                .view_manager
12017                .get_view_by_name(&name)
12018                .is_some_and(|other| other.id != *id),
12019        };
12020        if renaming_to_taken {
12021            self.view_modal.name_error = Some("name already exists".to_string());
12022            self.view_modal.form_focus = FormFocus::Name;
12023            return;
12024        }
12025
12026        let non_empty = |input: &widgets::text_input::TextInput| {
12027            let value = input.value().trim();
12028            (!value.is_empty()).then(|| value.to_string())
12029        };
12030        let match_criteria = view::MatchCriteria {
12031            exact_path: non_empty(&self.view_modal.exact_path_input).map(std::path::PathBuf::from),
12032            relative_path: non_empty(&self.view_modal.relative_path_input),
12033            path_pattern: non_empty(&self.view_modal.path_pattern_input),
12034            filename_pattern: non_empty(&self.view_modal.filename_pattern_input),
12035            // The columns the view's settings run on, not the query's output: the
12036            // next file is matched as loaded.
12037            schema_columns: if self.view_modal.schema_match_enabled {
12038                self.data_table_state.as_ref().map(|state| {
12039                    state
12040                        .source_schema()
12041                        .iter_names()
12042                        .map(|s| s.to_string())
12043                        .collect()
12044                })
12045            } else {
12046                None
12047            },
12048            schema_types: None,
12049            table: self.view_modal.table.clone(),
12050        };
12051        let description = {
12052            let value = self.view_modal.description_input.value();
12053            (!value.is_empty()).then(|| value.to_string())
12054        };
12055
12056        let saved = if let Some(editing_id) = self.view_modal.editing_view_id.clone() {
12057            let Some(mut view) = self.view_manager.get_view_by_id(&editing_id).cloned() else {
12058                return;
12059            };
12060            view.name = name;
12061            view.description = description;
12062            let stored_schema = view.match_criteria.schema_columns.take();
12063            view.match_criteria = match_criteria;
12064            let editing_the_active_view =
12065                self.active_view_id.as_deref() == Some(editing_id.as_str());
12066            // The same principle as the settings below: editing an unapplied
12067            // view must not swap the columns it matches on for the columns of
12068            // whatever table happens to be open. The toggle still works — off
12069            // drops the criterion — and the active view follows its table.
12070            if !editing_the_active_view
12071                && self.view_modal.schema_match_enabled
12072                && stored_schema.is_some()
12073            {
12074                view.match_criteria.schema_columns = stored_schema;
12075            }
12076            // The settings follow the table only while this view is the one
12077            // dressing it. Editing an unapplied view changes its name,
12078            // description and matching alone — it must not overwrite what
12079            // the view carries with whatever the table happens to show.
12080            if editing_the_active_view && let Some(state) = &self.data_table_state {
12081                view.settings = view_settings_of(state);
12082                view.settings.chart = self.saved_chart();
12083            }
12084            match self.view_manager.update_view(&view) {
12085                Ok(()) => true,
12086                Err(e) => {
12087                    // Deleted elsewhere, it has left the list too; otherwise the form
12088                    // stays, edits and all, to try again.
12089                    if self.view_manager.get_view_by_id(&editing_id).is_none() {
12090                        self.refresh_view_list();
12091                        self.view_modal.exit_form();
12092                    }
12093                    self.error_modal.show(format!("Error saving view: {e}"));
12094                    return;
12095                }
12096            }
12097        } else {
12098            self.create_view_from_current_state(name, description, match_criteria)
12099                .is_ok()
12100        };
12101        if saved {
12102            self.refresh_view_list();
12103            self.view_modal.exit_form();
12104        }
12105    }
12106
12107    /// The selected view's score breakdown, for the list's `i` popup.
12108    fn view_score_details(&self) -> Option<(String, String)> {
12109        let state = self.data_table_state.as_ref()?;
12110        let path = self.view_dataset()?;
12111        let idx = self.view_modal.table_state.selected()?;
12112        let row = self.view_modal.rows.get(idx)?;
12113        let view = &row.view;
12114
12115        let exact_path_match = view::exact_path_matches(&view.match_criteria, path);
12116        let relative_path_match = view::relative_path_matches(&view.match_criteria, path);
12117        let file_cols: std::collections::HashSet<&str> = state
12118            .source_schema()
12119            .iter_names()
12120            .map(|s| s.as_str())
12121            .collect();
12122        let exact_schema_match =
12123            view.match_criteria
12124                .schema_columns
12125                .as_ref()
12126                .is_some_and(|required| {
12127                    let required: std::collections::HashSet<&str> =
12128                        required.iter().map(|s| s.as_str()).collect();
12129                    required.is_subset(&file_cols) && file_cols.len() == required.len()
12130                });
12131
12132        let mut details = format!("Total score: {:.1}\n\n", row.score);
12133        if exact_path_match && exact_schema_match {
12134            details.push_str("Exact path + exact schema: 2000.0\n");
12135        } else if exact_path_match {
12136            details.push_str("Exact path: 1000.0\n");
12137        } else if relative_path_match && exact_schema_match {
12138            details.push_str("Relative path + exact schema: 1950.0\n");
12139        } else if relative_path_match {
12140            details.push_str("Relative path: 950.0\n");
12141        } else if exact_schema_match {
12142            details.push_str("Exact schema: 900.0\n");
12143        } else {
12144            if view::path_pattern_matches(&view.match_criteria, path) {
12145                details.push_str("Path pattern match: 50.0+\n");
12146            }
12147            if view::filename_pattern_matches(&view.match_criteria, path) {
12148                details.push_str("Filename pattern match: 30.0+\n");
12149            }
12150            if let Some(required_cols) = &view.match_criteria.schema_columns {
12151                let matching_count = required_cols
12152                    .iter()
12153                    .filter(|col| file_cols.contains(col.as_str()))
12154                    .count();
12155                if matching_count > 0 {
12156                    details.push_str(&format!(
12157                        "Partial schema match: {:.1} ({} columns)\n",
12158                        matching_count as f64 * 2.0,
12159                        matching_count
12160                    ));
12161                }
12162            }
12163        }
12164        if view.usage_count > 0 {
12165            details.push_str(&format!(
12166                "Usage count: {:.1}\n",
12167                (view.usage_count.min(10) as f64) * 1.0
12168            ));
12169        }
12170        if let Some(last_used) = view.last_used
12171            && let Ok(duration) = std::time::SystemTime::now().duration_since(last_used)
12172        {
12173            let days_since = duration.as_secs() / 86400;
12174            if days_since <= 7 {
12175                details.push_str("Recent usage: 5.0\n");
12176            } else if days_since <= 30 {
12177                details.push_str("Recent usage: 2.0\n");
12178            }
12179        }
12180        Some((format!("Score: {}", view.name), details))
12181    }
12182
12183    /// Open the help overlay on the keys of the screen it is opened at. No-op if it is
12184    /// already up.
12185    pub(crate) fn open_help_overlay(&mut self) {
12186        // A question or an error under the help would take its keys unseen.
12187        if self.help.is_open() || self.confirmation_modal.active || self.error_modal.active {
12188            return;
12189        }
12190        let context = self.keys_context();
12191        // The home filter types too once something is typed into it.
12192        let typing = self.text_field_focused()
12193            || (self.input_mode == InputMode::Home
12194                && !self.documentation.is_open()
12195                && (!self.home.filter.is_empty() || self.home.path_input_active));
12196        self.help.open(context, typing);
12197    }
12198
12199    /// Close the help when the screen under it changed on its own (a query that
12200    /// finished, a load that failed): its keys are for a screen that is gone, and
12201    /// Enter would press one of them on another. A question or an error that arrived
12202    /// under it takes the keys, so it closes for those too.
12203    fn close_help_left_behind(&mut self) {
12204        let left = self
12205            .help
12206            .context()
12207            .is_some_and(|shown| shown != self.keys_context());
12208        if left || self.confirmation_modal.active || self.error_modal.active {
12209            self.help.close();
12210        }
12211    }
12212
12213    /// The screen the keys typed now go to, as the key registry names it.
12214    pub fn keys_context(&self) -> datui_cli::keys::Context {
12215        use crate::analysis_modal::{AnalysisTool, AnalysisView};
12216        use datui_cli::keys::Context;
12217        if self.analysis_modal.active {
12218            return match self.analysis_modal.view {
12219                AnalysisView::DistributionDetail => Context::DistributionDetail,
12220                AnalysisView::CorrelationDetail => Context::CorrelationDetail,
12221                AnalysisView::Main => match self.analysis_modal.selected_tool {
12222                    Some(AnalysisTool::DistributionAnalysis) => Context::Distribution,
12223                    Some(AnalysisTool::CorrelationMatrix) => Context::Correlation,
12224                    Some(AnalysisTool::DataQuality) => Context::DataQuality,
12225                    Some(AnalysisTool::Describe) | None => Context::Describe,
12226                },
12227            };
12228        }
12229        if self.view_modal.active {
12230            return Context::Views;
12231        }
12232        match self.input_mode {
12233            InputMode::Normal => Context::Table,
12234            InputMode::Editing => match self.input_type {
12235                Some(InputType::Find) => Context::Find,
12236                _ => Context::Query,
12237            },
12238            InputMode::SortFilter => Context::SortFilter,
12239            InputMode::PivotMelt => Context::PivotMelt,
12240            InputMode::Export => Context::Export,
12241            InputMode::Copy => Context::Copy,
12242            InputMode::Inspect => Context::Inspector,
12243            InputMode::GoToColumn => Context::GoToColumn,
12244            InputMode::PickFormat => Context::FormatPicker,
12245            InputMode::Retype => Context::Retype,
12246            InputMode::Combine => Context::Combine,
12247            InputMode::PickTable => Context::TablePicker,
12248            InputMode::Sample => Context::Sample,
12249            InputMode::Info => Context::Info,
12250            InputMode::Chart => Context::Chart,
12251            InputMode::Home if self.documentation.is_open() => Context::Documentation,
12252            InputMode::Home => Context::Home,
12253            InputMode::Hex => Context::Hex,
12254            InputMode::ValueCounts => Context::ValueCounts,
12255        }
12256    }
12257
12258    /// True while the confirmation modal is asking whether to download a remote file,
12259    /// or to read a large one whole into memory.
12260    ///
12261    /// Those are the confirmations the user has to be able to walk away from: the
12262    /// size probe behind a download can take fifteen seconds, and the answer to
12263    /// "actually, never mind" is the home screen, not the exit.
12264    pub fn awaiting_open_confirmation(&self) -> bool {
12265        self.confirmation_modal.active && self.loading.asking()
12266    }
12267
12268    fn key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
12269        self.debug.on_key(event);
12270
12271        // A completion flash lives until the next key: whatever this key does,
12272        // the bar's line about the last action is stale now.
12273        self.flash = None;
12274        // A key puts back a header being carried, so a release later moves nothing.
12275        self.cancel_drag();
12276
12277        let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
12278        // Ctrl-Q quits from anywhere, before any mode gets a say — including a mode
12279        // with no CONTROL arm of its own (the chart view) that would otherwise swallow
12280        // it while busy.
12281        if ctrl && event.code == KeyCode::Char('q') {
12282            return Some(AppEvent::Exit);
12283        }
12284        // Ctrl-C too, a text field included: a terminal user's reflex for leaving, and
12285        // the field copies with Alt+W instead.
12286        if ctrl && event.code == KeyCode::Char('c') {
12287            return Some(AppEvent::Exit);
12288        }
12289
12290        // The context menu takes its keys first. A line chosen closes it and presses
12291        // its key, offered as typed (as Enter on a help line is); any other key closes
12292        // it and then acts as it would have. A menu something else has covered since
12293        // (an error, a load's screen) is gone.
12294        if !self.menu_showing() {
12295            self.context_menu = None;
12296        }
12297        if let Some(menu) = self.context_menu.as_mut() {
12298            match menu.key(event) {
12299                context_menu::MenuKey::Moved => return None,
12300                context_menu::MenuKey::Close => {
12301                    self.context_menu = None;
12302                    return None;
12303                }
12304                context_menu::MenuKey::Run(key) => {
12305                    self.context_menu = None;
12306                    return Some(AppEvent::Press(key));
12307                }
12308                context_menu::MenuKey::Do(action) => {
12309                    self.context_menu = None;
12310                    self.menu_action(action);
12311                    return None;
12312                }
12313                context_menu::MenuKey::Other => self.context_menu = None,
12314            }
12315        }
12316
12317        // Acts at once (see `hard_escape_while_busy`), ahead of the keys held behind
12318        // the view.
12319        if event.code == KeyCode::Esc && self.view_applying() {
12320            self.cancel_view();
12321            return None;
12322        }
12323        // The same for a find that is reading.
12324        if event.code == KeyCode::Esc && self.finding() {
12325            self.cancel_find();
12326            return None;
12327        }
12328        // And for a count of footers, at the table its progress line is on.
12329        if event.code == KeyCode::Esc
12330            && self.input_mode == InputMode::Normal
12331            && self.in_normal_table_view()
12332            && self.footers_counted().is_some()
12333        {
12334            self.stop_count();
12335            return None;
12336        }
12337
12338        if event.code == KeyCode::Esc
12339            && self.input_mode == InputMode::Normal
12340            && !self.analysis_modal.active
12341            && !self.error_modal.active
12342            && !self.confirmation_modal.active
12343            && self.return_from_quality_evidence(true)
12344        {
12345            return None;
12346        }
12347
12348        // F1 opens help first so no other branch (e.g. Editing) can consume it; again,
12349        // it closes it.
12350        if event.code == KeyCode::F(1) {
12351            if self.help.is_open() {
12352                self.help.close();
12353            } else {
12354                self.open_help_overlay();
12355            }
12356            return None;
12357        }
12358
12359        // Home owns the whole screen and every key while it is up — except under a
12360        // modal or the help overlay. Both render over home unconditionally, so if
12361        // home also ate their keys they would be undismissable, and Esc would try
12362        // to leave home instead.
12363        if self.input_mode == InputMode::Home
12364            && !self.confirmation_modal.active
12365            && !self.error_modal.active
12366            && !self.help.is_open()
12367        {
12368            return self.home_key(event);
12369        }
12370
12371        // Ctrl+O goes home from anywhere, including mid-load. That is what makes
12372        // browsing cheap: opening the wrong 300 MB file costs one keystroke to leave,
12373        // not a wait for it to finish.
12374        if event.code == KeyCode::Char('o')
12375            && event.modifiers.contains(KeyModifiers::CONTROL)
12376            && (!self.confirmation_modal.active || self.awaiting_open_confirmation())
12377        {
12378            self.help.close();
12379            self.enter_home();
12380            return None;
12381        }
12382
12383        // Handle modals first - they have highest priority
12384        // Confirmation modal (for overwrite)
12385        if self.confirmation_modal.active {
12386            match event.code {
12387                KeyCode::Left | KeyCode::Char('h') => {
12388                    self.confirmation_modal.focus_yes = true;
12389                }
12390                KeyCode::Right | KeyCode::Char('l') => {
12391                    self.confirmation_modal.focus_yes = false;
12392                }
12393                KeyCode::Tab => {
12394                    // Toggle between Yes and No
12395                    self.confirmation_modal.focus_yes = !self.confirmation_modal.focus_yes;
12396                }
12397                // ←→ carry the choice, so ↑↓ (k/j) scroll a long question; the
12398                // render clamps the offset.
12399                KeyCode::Up | KeyCode::Char('k') => {
12400                    self.confirmation_modal.scroll =
12401                        self.confirmation_modal.scroll.saturating_sub(1);
12402                }
12403                KeyCode::Down | KeyCode::Char('j') => {
12404                    self.confirmation_modal.scroll =
12405                        self.confirmation_modal.scroll.saturating_add(1);
12406                }
12407                KeyCode::Enter if self.pending_leave.is_some() => {
12408                    let stop = self.confirmation_modal.focus_yes;
12409                    self.confirmation_modal.hide();
12410                    return self.leave_recording(stop);
12411                }
12412                KeyCode::Enter => {
12413                    if self.confirmation_modal.focus_yes {
12414                        // The confirmations that are not about overwriting a file come
12415                        // first: reading every row, and forgetting recents.
12416                        if std::mem::take(&mut self.pending_read_all) {
12417                            self.confirmation_modal.hide();
12418                            // Every row is a sample method like the others: it shows in
12419                            // the strip, and `s` changes it back.
12420                            let sample = sampling::Sample {
12421                                method: sampling::SampleMethod::EveryRow,
12422                                ..self.analysis_modal.sample.clone()
12423                            };
12424                            return self.apply_sample(sample);
12425                        }
12426                        if let Some(url) = self.pending_link.take() {
12427                            self.confirmation_modal.hide();
12428                            return Some(AppEvent::OpenLink(url));
12429                        }
12430                        if self.pending_clear_recents {
12431                            self.pending_clear_recents = false;
12432                            self.confirmation_modal.hide();
12433                            self.cache.clear_recents();
12434                            self.home_refresh();
12435                            self.home.status = None;
12436                            return None;
12437                        }
12438                        // A full scan agreed to: Setup runs, past the question.
12439                        if self.analysis_modal.data_quality_confirm_run {
12440                            self.confirmation_modal.hide();
12441                            let event = self.run_quality_setup();
12442                            // Asked once: a run that waits or is refused asks again.
12443                            self.analysis_modal.data_quality_confirm_run = false;
12444                            return event;
12445                        }
12446                        if std::mem::take(&mut self.pending_hide_examples) {
12447                            self.confirmation_modal.hide();
12448                            self.cache.hide_examples();
12449                            self.home_refresh();
12450                            self.home.select_first_entry();
12451                            return None;
12452                        }
12453                        if let Some(id) = self.pending_delete_view.take() {
12454                            self.confirmation_modal.hide();
12455                            if self.view_manager.delete_view(&id).is_ok() {
12456                                self.refresh_view_list();
12457                            }
12458                            return None;
12459                        }
12460                        if let Some(place) = self.pending_forget_place.take() {
12461                            self.confirmation_modal.hide();
12462                            let paths = self.home.recents_in(&place);
12463                            self.cache.forget_recents(&paths);
12464                            self.home_refresh();
12465                            self.home.status = None;
12466                            return None;
12467                        }
12468                        // The overwrite was agreed to: each export may now replace the
12469                        // file it asked about, and only through that answer.
12470                        if let Some((path, format)) = self.pending_quality_export.take() {
12471                            self.confirmation_modal.hide();
12472                            return Some(AppEvent::QualityReportExport(
12473                                path,
12474                                format,
12475                                Overwrite::Replace,
12476                            ));
12477                        }
12478                        if let Some(request) = self.pending_chart_export.take() {
12479                            self.confirmation_modal.hide();
12480                            return Some(AppEvent::ChartExport(ChartExportRequest {
12481                                overwrite: Overwrite::Replace,
12482                                ..request
12483                            }));
12484                        }
12485                        if let Some(request) = self.pending_export.take() {
12486                            self.confirmation_modal.hide();
12487                            return Some(AppEvent::Export(ExportRequest {
12488                                overwrite: Overwrite::Replace,
12489                                ..request
12490                            }));
12491                        }
12492                        if let Some((format, header)) = self.pending_copy.take() {
12493                            self.confirmation_modal.hide();
12494                            return Some(AppEvent::CopyTable { format, header });
12495                        }
12496                        if self.loading.asking() {
12497                            self.confirmation_modal.hide();
12498                            // The loader lets go of its hold on the generation as the
12499                            // download or the read starts, and its job takes it before
12500                            // anything else can look.
12501                            let step = self.loading.confirmed();
12502                            return self.run_load_step(step);
12503                        }
12504                    } else {
12505                        self.pending_clear_recents = false;
12506                        self.pending_link = None;
12507                        self.pending_read_all = false;
12508                        self.pending_forget_place = None;
12509                        self.pending_delete_view = None;
12510                        self.pending_hide_examples = false;
12511                        // Declining the full read leaves the draft staged, and the
12512                        // sample and report as they were.
12513                        self.analysis_modal.data_quality_confirm_run = false;
12514                        // Declining an overwrite returns to the filled form:
12515                        // the typed path, format and options survive the No.
12516                        if self.pending_chart_export.take().is_some() {
12517                            self.chart_export_modal.resume();
12518                        }
12519                        // The report's dialog stays open behind the question.
12520                        self.pending_quality_export = None;
12521                        if self.pending_export.take().is_some() {
12522                            self.export_modal.resume();
12523                            self.input_mode = InputMode::Export;
12524                        }
12525                        self.pending_copy = None;
12526                        if self.loading.asking() {
12527                            self.enter_home();
12528                            return None;
12529                        }
12530                        self.confirmation_modal.hide();
12531                    }
12532                }
12533                KeyCode::Esc => {
12534                    // Disarmed on every exit from the modal, so a declined confirmation
12535                    // cannot fire against whatever the *next* one is asking about.
12536                    self.pending_clear_recents = false;
12537                    self.pending_link = None;
12538                    self.pending_read_all = false;
12539                    self.pending_forget_place = None;
12540                    self.pending_delete_view = None;
12541                    self.pending_hide_examples = false;
12542                    self.analysis_modal.data_quality_confirm_run = false;
12543                    // Staying: the recording goes on, and so does the view.
12544                    self.pending_leave = None;
12545                    // Declining an overwrite returns to the filled form: the
12546                    // typed path, format and options survive the Esc.
12547                    if self.pending_chart_export.take().is_some() {
12548                        self.chart_export_modal.resume();
12549                    }
12550                    self.pending_quality_export = None;
12551                    if self.pending_export.take().is_some() {
12552                        self.export_modal.resume();
12553                        self.input_mode = InputMode::Export;
12554                    }
12555                    self.pending_copy = None;
12556                    if self.loading.asking() {
12557                        // Declining a download used to quit datui outright, which made
12558                        // a remote open the one thing in the app you could not back out
12559                        // of. `enter_home` puts the open down and hides this.
12560                        self.enter_home();
12561                        return None;
12562                    }
12563                    self.confirmation_modal.hide();
12564                }
12565                _ => {}
12566            }
12567            return None;
12568        }
12569        // Error modal
12570        if self.error_modal.active {
12571            match event.code {
12572                // A long diagnostic scrolls; the render clamps the offset.
12573                KeyCode::Up | KeyCode::Char('k') => {
12574                    self.error_modal.scroll = self.error_modal.scroll.saturating_sub(1);
12575                    return None;
12576                }
12577                KeyCode::Down | KeyCode::Char('j') => {
12578                    self.error_modal.scroll = self.error_modal.scroll.saturating_add(1);
12579                    return None;
12580                }
12581                KeyCode::PageUp => {
12582                    self.error_modal.scroll = self.error_modal.scroll.saturating_sub(8);
12583                    return None;
12584                }
12585                KeyCode::PageDown => {
12586                    self.error_modal.scroll = self.error_modal.scroll.saturating_add(8);
12587                    return None;
12588                }
12589                KeyCode::Esc | KeyCode::Enter => {
12590                    self.error_modal.hide();
12591                    // With nothing loaded, dismissing the error would otherwise leave
12592                    // an empty table and no indication of what to do. Go back to the
12593                    // list the dataset was chosen from, carrying the reason, so the
12594                    // next choice is one keystroke away.
12595                    if self.data_table_state.is_none() {
12596                        let reason = self.last_load_error.take();
12597                        self.enter_home();
12598                        self.home.status = reason;
12599                    }
12600                }
12601                _ => {}
12602            }
12603            return None;
12604        }
12605
12606        // Main table: the column cursor keys (before help/mode blocks so they always work
12607        // in Normal). No is_press()/is_release() check: some terminals do not report key
12608        // kind correctly. Exclude view/analysis modals so they can handle Left/Right
12609        // themselves.
12610        let in_main_table = !(self.input_mode != InputMode::Normal
12611            || self.help.is_open()
12612            || self.view_modal.active
12613            || self.analysis_modal.active);
12614        // The footer offers the column's keys once the column cursor moves, until a
12615        // key that is not about the column.
12616        if in_main_table && event.is_press() {
12617            self.column_hints = Self::column_cursor_key(event).is_some()
12618                || (self.column_hints
12619                    && matches!(
12620                        event.code,
12621                        KeyCode::Char(
12622                            '+' | '-' | 'F' | '[' | ']' | 'H' | 'L' | '<' | '>' | '=' | 'w'
12623                        )
12624                    ));
12625        }
12626        if in_main_table
12627            && let Some(mv) = Self::column_cursor_key(event)
12628            && let Some(state) = self.data_table_state.as_mut()
12629        {
12630            state.move_cursor(mv);
12631            if self.debug.enabled {
12632                self.debug.last_action = format!("move_cursor({mv:?})");
12633            }
12634            return None;
12635        }
12636
12637        // The help owns the keys while it is up. Enter on a line closes it and presses
12638        // that line's key: handed back as this key's follow-up, it reaches the screen
12639        // under the help the way a typed key does, held while the app is busy.
12640        self.close_help_left_behind();
12641        if self.help.is_open() {
12642            return match self.help.key(event) {
12643                help::HelpKey::Press(key) => Some(AppEvent::Press(key)),
12644                help::HelpKey::Stay | help::HelpKey::Closed => None,
12645            };
12646        }
12647
12648        if event.code == KeyCode::Char('?') {
12649            let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
12650            // The home screen always accepts characters, into its filter or path input.
12651            let in_text_input = self.text_field_focused() || self.input_mode == InputMode::Home;
12652            // Ctrl-? always opens help; bare ? only when not in a text field
12653            if ctrl_help || !in_text_input {
12654                self.open_help_overlay();
12655                return None;
12656            }
12657        }
12658
12659        if self.input_mode == InputMode::SortFilter {
12660            return self.sort_filter_key(event);
12661        }
12662
12663        if self.input_mode == InputMode::Export {
12664            return self.export_key(event);
12665        }
12666
12667        if self.input_mode == InputMode::Sample {
12668            return self.table_sample_form_key(event);
12669        }
12670
12671        if self.input_mode == InputMode::Inspect {
12672            return self.inspector_key(event);
12673        }
12674
12675        if self.input_mode == InputMode::ValueCounts {
12676            return self.value_counts_key(event);
12677        }
12678
12679        if self.input_mode == InputMode::Hex {
12680            return self.hex_key(event);
12681        }
12682
12683        if self.input_mode == InputMode::GoToColumn {
12684            self.go_to_column_key(event);
12685            return None;
12686        }
12687
12688        if self.input_mode == InputMode::PickFormat {
12689            return self.format_picker_key(event);
12690        }
12691
12692        if self.input_mode == InputMode::Retype {
12693            return self.retype_key(event);
12694        }
12695
12696        if self.input_mode == InputMode::Combine {
12697            return self.combine_key(event);
12698        }
12699
12700        if self.input_mode == InputMode::PickTable {
12701            return self.table_picker_key(event);
12702        }
12703
12704        if self.input_mode == InputMode::Copy {
12705            return self.copy_key(event);
12706        }
12707
12708        if self.input_mode == InputMode::PivotMelt {
12709            return self.pivot_melt_key(event);
12710        }
12711
12712        if self.input_mode == InputMode::Info {
12713            return self.info_key(event);
12714        }
12715
12716        if self.input_mode == InputMode::Chart {
12717            return self.chart_key(event);
12718        }
12719
12720        if self.analysis_modal.active {
12721            return self.analysis_key(event);
12722        }
12723
12724        if self.view_modal.active {
12725            return self.view_key(event);
12726        }
12727
12728        if self.input_mode == InputMode::Editing {
12729            return self.editing_key(event);
12730        }
12731
12732        const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
12733
12734        const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
12735
12736        const DOWN_KEYS: [KeyCode; 2] = [KeyCode::Down, KeyCode::Char('j')];
12737
12738        const UP_KEYS: [KeyCode; 2] = [KeyCode::Up, KeyCode::Char('k')];
12739
12740        // The letter arms below are unmodified keys. Without this guard the
12741        // bare-`Char` matches also fired with Ctrl or Alt held, so Ctrl+E
12742        // opened Export and Ctrl+R reversed — bindings nobody declared.
12743        // Paging (Ctrl+F/B/D/U) is the only modified set this match owns;
12744        // the global escapes were handled before reaching here.
12745        if event
12746            .modifiers
12747            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
12748            && !matches!(event.code, KeyCode::Char('f' | 'b' | 'd' | 'u'))
12749        {
12750            return None;
12751        }
12752
12753        match event.code {
12754            // q pops the context: opened from the home screen, it returns
12755            // there; launched straight onto a file, it quits as it always
12756            // has. Q and Ctrl+Q stay unconditional.
12757            KeyCode::Char('q') => {
12758                if self.opened_from_home {
12759                    self.enter_home();
12760                    None
12761                } else {
12762                    Some(AppEvent::Exit)
12763                }
12764            }
12765            KeyCode::Char('Q') => Some(AppEvent::Exit),
12766            KeyCode::Char('R') => Some(AppEvent::Reset),
12767            KeyCode::Char('H' | 'L') if event.is_press() => {
12768                self.move_cursor_column(event.code == KeyCode::Char('L'))
12769            }
12770            KeyCode::Char('+' | '-') if event.is_press() => {
12771                self.quick_filter(event.code == KeyCode::Char('+'))
12772            }
12773            KeyCode::Char('#') => {
12774                let renumbered = self
12775                    .data_table_state
12776                    .as_mut()
12777                    .is_some_and(|state| state.deferred(|s| s.toggle_row_numbers()));
12778                if renumbered {
12779                    self.spawn_async_collect(Self::LOADING_BUFFER);
12780                }
12781                None
12782            }
12783            // The column cursor's width, applied as typed so its effect shows (#647).
12784            KeyCode::Char('<' | '>' | '=' | 'w')
12785                if event.is_press() && !event.modifiers.contains(KeyModifiers::CONTROL) =>
12786            {
12787                if let Some(state) = self.data_table_state.as_mut()
12788                    && let Some(name) = state.current_column().map(str::to_string)
12789                {
12790                    // From the width on screen: `>` on a column filling the right edge
12791                    // widens what is seen.
12792                    let (choice, shown) = (state.width_choice(&name), state.on_screen_width(&name));
12793                    let width = match event.code {
12794                        KeyCode::Char('<') => choice.narrower(shown),
12795                        KeyCode::Char('>') => choice.wider(shown),
12796                        KeyCode::Char('=') => WidthChoice::Fit,
12797                        _ => WidthChoice::Auto,
12798                    };
12799                    state.set_width_choices([(name, width)]);
12800                }
12801                None
12802            }
12803            // `/` finds, as in less and vim; `f` too. Ctrl+F pages down, below.
12804            KeyCode::Char('/' | 'f')
12805                if event.is_press() && !event.modifiers.contains(KeyModifiers::CONTROL) =>
12806            {
12807                self.open_find();
12808                None
12809            }
12810            KeyCode::Char('[' | ']') if event.is_press() => {
12811                self.sort_by_cursor_column(event.code == KeyCode::Char(']'))
12812            }
12813            KeyCode::Char('n') if event.is_press() => {
12814                self.find_again(find::Direction::Next);
12815                None
12816            }
12817            KeyCode::Char('N') if event.is_press() => {
12818                self.find_again(find::Direction::Previous);
12819                None
12820            }
12821            KeyCode::Char('D') => {
12822                // The type row is drawn from the schema the table already has, so
12823                // this is a render-time flip like `,`. Session-only.
12824                self.dtype_row = !self.dtype_row;
12825                if self.debug.enabled {
12826                    self.debug.last_action = format!(
12827                        "toggle_dtype_row({})",
12828                        if self.dtype_row { "on" } else { "off" }
12829                    );
12830                }
12831                None
12832            }
12833            KeyCode::Char('F') => {
12834                self.open_value_counts();
12835                None
12836            }
12837            KeyCode::Char(',') => {
12838                // Formatting is applied at render time, so this takes effect on
12839                // the next frame with no re-collect. Session-only: the config
12840                // file stays the source of truth at launch.
12841                self.number_format.enabled = !self.number_format.enabled;
12842                if self.debug.enabled {
12843                    self.debug.last_action = format!(
12844                        "toggle_number_format({})",
12845                        if self.number_format.enabled {
12846                            "on"
12847                        } else {
12848                            "off"
12849                        }
12850                    );
12851                }
12852                None
12853            }
12854            KeyCode::Esc => {
12855                // The find is the nearest layer: its mark goes first, then a drill.
12856                if self.find_shown() {
12857                    self.find.active = None;
12858                    return None;
12859                }
12860                // A sample being drawn stops, keeping the rows so far.
12861                if self.sample_drawing() {
12862                    self.stop_sample_draw();
12863                    return None;
12864                }
12865                let mut from_counts = false;
12866                let drilled_up = if let Some(ref mut state) = self.data_table_state {
12867                    if state.is_drilled_down() {
12868                        from_counts = state.drilled_into_value();
12869                        let _ = state.deferred(|s| s.drill_up());
12870                        true
12871                    } else {
12872                        false
12873                    }
12874                } else {
12875                    false
12876                };
12877                if drilled_up {
12878                    self.sync_sort_filter_modal();
12879                }
12880                // Out of a drill from Value Counts, back to the counts it came from:
12881                // the view is the one they were read of.
12882                if from_counts
12883                    && std::mem::take(&mut self.value_counts.drill_return)
12884                    && let Some(state) = self.data_table_state.as_ref()
12885                {
12886                    self.value_counts.rebase(state.len_generation());
12887                    self.input_mode = InputMode::ValueCounts;
12888                }
12889                if drilled_up {
12890                    self.spawn_async_collect(Self::LOADING_BUFFER);
12891                    return None;
12892                }
12893                // Out of a follow: the rows read so far stay.
12894                if let Some(state) = self.data_table_state.as_mut()
12895                    && state.follow().is_some()
12896                {
12897                    state.stop_following();
12898                    self.flash_note("Stopped following".to_string());
12899                }
12900                // Escape no longer exits - use 'q' or Ctrl-C to exit
12901                // (Info modal handles Esc in its own block)
12902                None
12903            }
12904            KeyCode::Char('t') if event.is_press() => self.toggle_follow(),
12905            code if RIGHT_KEYS.contains(&code) || LEFT_KEYS.contains(&code) => {
12906                if let Some(ref mut state) = self.data_table_state {
12907                    state.move_cursor(if RIGHT_KEYS.contains(&code) {
12908                        crate::widgets::column_paging::CursorMove::Right
12909                    } else {
12910                        crate::widgets::column_paging::CursorMove::Left
12911                    });
12912                }
12913                None
12914            }
12915            code if event.is_press() && DOWN_KEYS.contains(&code) => {
12916                let would_collect = self
12917                    .data_table_state
12918                    .as_ref()
12919                    .map(|s| s.scroll_would_trigger_collect(1))
12920                    .unwrap_or(false);
12921                if would_collect {
12922                    self.busy = true;
12923                    Some(AppEvent::DoScrollNext)
12924                } else {
12925                    if let Some(ref mut s) = self.data_table_state {
12926                        s.select_next();
12927                    }
12928                    None
12929                }
12930            }
12931            code if event.is_press() && UP_KEYS.contains(&code) => {
12932                let would_collect = self
12933                    .data_table_state
12934                    .as_ref()
12935                    .map(|s| s.scroll_would_trigger_collect(-1))
12936                    .unwrap_or(false);
12937                if would_collect {
12938                    self.busy = true;
12939                    Some(AppEvent::DoScrollPrev)
12940                } else {
12941                    if let Some(ref mut s) = self.data_table_state {
12942                        s.select_previous();
12943                    }
12944                    None
12945                }
12946            }
12947            KeyCode::PageDown if event.is_press() => {
12948                let would_collect = self
12949                    .data_table_state
12950                    .as_ref()
12951                    .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
12952                    .unwrap_or(false);
12953                if would_collect {
12954                    self.busy = true;
12955                    Some(AppEvent::DoScrollDown)
12956                } else {
12957                    if let Some(ref mut s) = self.data_table_state {
12958                        s.page_down();
12959                    }
12960                    None
12961                }
12962            }
12963            KeyCode::Home if event.is_press() => self.jump_key(AppEvent::DoScrollHome),
12964            KeyCode::End | KeyCode::Char('G') if event.is_press() => {
12965                self.jump_key(AppEvent::DoScrollEnd)
12966            }
12967            KeyCode::Char('f')
12968                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
12969            {
12970                let would_collect = self
12971                    .data_table_state
12972                    .as_ref()
12973                    .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
12974                    .unwrap_or(false);
12975                if would_collect {
12976                    self.busy = true;
12977                    Some(AppEvent::DoScrollDown)
12978                } else {
12979                    if let Some(ref mut s) = self.data_table_state {
12980                        s.page_down();
12981                    }
12982                    None
12983                }
12984            }
12985            KeyCode::Char('b')
12986                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
12987            {
12988                let would_collect = self
12989                    .data_table_state
12990                    .as_ref()
12991                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
12992                    .unwrap_or(false);
12993                if would_collect {
12994                    self.busy = true;
12995                    Some(AppEvent::DoScrollUp)
12996                } else {
12997                    if let Some(ref mut s) = self.data_table_state {
12998                        s.page_up();
12999                    }
13000                    None
13001                }
13002            }
13003            KeyCode::Char('d')
13004                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
13005            {
13006                let half = self
13007                    .data_table_state
13008                    .as_ref()
13009                    .map(|s| (s.visible_rows / 2).max(1) as i64)
13010                    .unwrap_or(1);
13011                let would_collect = self
13012                    .data_table_state
13013                    .as_ref()
13014                    .map(|s| s.scroll_would_trigger_collect(half))
13015                    .unwrap_or(false);
13016                if would_collect {
13017                    self.busy = true;
13018                    Some(AppEvent::DoScrollHalfDown)
13019                } else {
13020                    if let Some(ref mut s) = self.data_table_state {
13021                        s.half_page_down();
13022                    }
13023                    None
13024                }
13025            }
13026            KeyCode::Char('u')
13027                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
13028            {
13029                let half = self
13030                    .data_table_state
13031                    .as_ref()
13032                    .map(|s| (s.visible_rows / 2).max(1) as i64)
13033                    .unwrap_or(1);
13034                let would_collect = self
13035                    .data_table_state
13036                    .as_ref()
13037                    .map(|s| s.scroll_would_trigger_collect(-half))
13038                    .unwrap_or(false);
13039                if would_collect {
13040                    self.busy = true;
13041                    Some(AppEvent::DoScrollHalfUp)
13042                } else {
13043                    if let Some(ref mut s) = self.data_table_state {
13044                        s.half_page_up();
13045                    }
13046                    None
13047                }
13048            }
13049            KeyCode::PageUp if event.is_press() => {
13050                let would_collect = self
13051                    .data_table_state
13052                    .as_ref()
13053                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
13054                    .unwrap_or(false);
13055                if would_collect {
13056                    self.busy = true;
13057                    Some(AppEvent::DoScrollUp)
13058                } else {
13059                    if let Some(ref mut s) = self.data_table_state {
13060                        s.page_up();
13061                    }
13062                    None
13063                }
13064            }
13065            KeyCode::Enter if event.is_press() => {
13066                if self.input_mode != InputMode::Normal {
13067                    return None;
13068                }
13069                // With no group to drill into, Enter is Space: the row inspector.
13070                if self.enter_inspects() {
13071                    self.open_inspector();
13072                    return None;
13073                }
13074                self.drill_selected_row();
13075                None
13076            }
13077            KeyCode::Char('i') if event.is_press() => {
13078                if let Some(state) = self.data_table_state.as_mut() {
13079                    // Unread notes put the panel's Notes tab in front — that is
13080                    // what the accented `i` chip was promising. Read before the
13081                    // mark, which is what retires the accent.
13082                    let unseen = state.notes_unseen();
13083                    state.mark_notes_seen();
13084                    if unseen {
13085                        self.info_modal
13086                            .open_on(crate::widgets::info::InfoTab::Notes);
13087                    } else if state.format_detail().is_some_and(|d| d.first) {
13088                        // A table whose columns are the same for every file (a model's
13089                        // tensors, an audio file's frames, a VCD dump's changes): what
13090                        // is particular to it is its own tab.
13091                        self.info_modal
13092                            .open_on(crate::widgets::info::InfoTab::Format);
13093                    } else {
13094                        self.info_modal.open();
13095                    }
13096                    // A list of the file's tables starts its cursor on the one open.
13097                    if let Some(detail) = state.format_detail()
13098                        && let Some(at) = detail.list.iter().position(|(key, _)| {
13099                            detail.table.as_ref() == Some(key) && detail.tables.contains(key)
13100                        })
13101                    {
13102                        self.info_modal.detail_selected = at;
13103                    }
13104                    self.input_mode = InputMode::Info;
13105                    self.read_file_facts();
13106                    self.count_unfit();
13107                }
13108                None
13109            }
13110            KeyCode::Char(':') if event.is_press() => {
13111                self.open_command_line();
13112                None
13113            }
13114            KeyCode::Char('V') => {
13115                // Apply the best view whose criteria match this dataset. When none
13116                // does, the answer is not silence and not the best-scored stranger: the
13117                // list opens, so the user sees what exists and picks — or saves one.
13118                if let Some(ref state) = self.data_table_state
13119                    && let Some(dataset) = self.view_dataset()
13120                {
13121                    match self
13122                        .view_manager
13123                        .get_most_relevant(dataset, state.source_schema())
13124                    {
13125                        Some((view, why)) => {
13126                            if let Err(e) = self.apply_matched_view(&view, why) {
13127                                self.error_modal.show(format!("Error applying view: {}", e));
13128                            }
13129                        }
13130                        None => self.open_view_list(),
13131                    }
13132                }
13133                None
13134            }
13135            KeyCode::Char('v') => {
13136                self.open_view_list();
13137                None
13138            }
13139            KeyCode::Char('S') => {
13140                if self.input_mode == InputMode::Normal {
13141                    self.open_table_sample_form();
13142                }
13143                None
13144            }
13145            KeyCode::Char('s') => {
13146                if self.data_table_state.is_some() {
13147                    // Rebuilt from the table's applied state, never from what the modal
13148                    // held last time: an edit staged and then canceled must not arrive
13149                    // pre-staged, one Apply away from committing silently.
13150                    self.sync_sort_filter_modal();
13151                    let current = self
13152                        .data_table_state
13153                        .as_ref()
13154                        .and_then(|state| state.current_column())
13155                        .map(str::to_string);
13156                    self.sort_filter_modal.open(
13157                        self.history_limit,
13158                        &self.theme,
13159                        current.as_deref(),
13160                    );
13161                    self.input_mode = InputMode::SortFilter;
13162                }
13163                None
13164            }
13165            KeyCode::Char('r') => {
13166                if let Some(state) = &mut self.data_table_state {
13167                    state.deferred(DataTableState::reverse);
13168                    self.spawn_async_collect("Sorting...");
13169                }
13170                None
13171            }
13172            KeyCode::Char('a') => {
13173                // Open analysis modal; no computation until user selects a tool from the sidebar (Enter)
13174                if self.data_table_state.is_some()
13175                    && self.input_mode == InputMode::Normal
13176                    && self.quality_evidence_return.is_none()
13177                {
13178                    // The results a close put down come back on the view they are of.
13179                    let view = self.data_table_state.as_ref().map(|s| s.len_generation());
13180                    self.analysis_modal.open(view);
13181                    // The sample outlives a close, but its scope names this
13182                    // dataset's rows: another dataset starts from its current view.
13183                    if self.analysis_modal.sample_dataset != Some(self.dataset_generation) {
13184                        self.analysis_modal.sample.scope = data_quality::QualityScope::CurrentView;
13185                        self.analysis_modal.sample_dataset = Some(self.dataset_generation);
13186                    }
13187                    // A view with a sample: every tool reads it, whole.
13188                    let sampled = self
13189                        .data_table_state
13190                        .as_ref()
13191                        .is_some_and(|state| state.sampled().is_some());
13192                    self.analysis_modal.follow_view_sample(sampled);
13193                    self.sync_quality_plan();
13194                }
13195                None
13196            }
13197            KeyCode::Char('c') => {
13198                if let Some(state) = &self.data_table_state
13199                    && self.input_mode == InputMode::Normal
13200                {
13201                    let numeric_columns: Vec<String> = state
13202                        .schema()
13203                        .iter()
13204                        .filter(|(_, dtype)| dtype.is_numeric())
13205                        .map(|(name, _)| name.to_string())
13206                        .collect();
13207                    let datetime_columns: Vec<String> = state
13208                        .schema()
13209                        .iter()
13210                        .filter(|(_, dtype)| {
13211                            matches!(
13212                                dtype,
13213                                DataType::Datetime(_, _) | DataType::Date | DataType::Time
13214                            )
13215                        })
13216                        .map(|(name, _)| name.to_string())
13217                        .collect();
13218                    let category_columns: Vec<String> = state
13219                        .schema()
13220                        .iter()
13221                        .filter(|(_, dtype)| chart_data::is_category_dtype(dtype))
13222                        .map(|(name, _)| name.to_string())
13223                        .collect();
13224                    // Show Me: the chart starts from the cursor column's type.
13225                    let cursor = state.current_column().and_then(|name| {
13226                        let dtype = state.schema().get(name)?.clone();
13227                        Some((name.to_string(), dtype))
13228                    });
13229                    // Dates and datetimes take a time bucket; a time of day does not.
13230                    let bucketable_columns: Vec<String> = state
13231                        .schema()
13232                        .iter()
13233                        .filter(|(_, dtype)| {
13234                            matches!(dtype, DataType::Datetime(_, _) | DataType::Date)
13235                        })
13236                        .map(|(name, _)| name.to_string())
13237                        .collect();
13238                    self.chart_modal.series_cap = Some(self.theme.series_colors().len());
13239                    self.chart_modal.row_order = self.view_state().sort;
13240                    let sampled = state.sampled().is_some();
13241                    self.chart_modal.open(
13242                        ChartColumns {
13243                            numeric: &numeric_columns,
13244                            datetime: &datetime_columns,
13245                            bucketable: &bucketable_columns,
13246                            category: &category_columns,
13247                        },
13248                        cursor.as_ref().map(|(name, dtype)| (name.as_str(), dtype)),
13249                        Some(self.app_config.analysis.chart_rows),
13250                        self.app_config.analysis.chart_grid,
13251                        self.dataset_generation,
13252                    );
13253                    // A view's sample is read whole: the chart has no sample of its own.
13254                    if sampled {
13255                        self.chart_modal.row_limit = None;
13256                    } else if self.chart_modal.view_sampled {
13257                        self.chart_modal.row_limit = Some(self.chart_modal.sample_rows);
13258                    }
13259                    self.chart_modal.view_sampled = sampled;
13260                    self.chart_cache.clear();
13261                    self.input_mode = InputMode::Chart;
13262                }
13263                None
13264            }
13265            KeyCode::Char('p') => {
13266                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
13267                    self.open_pivot_builder();
13268                }
13269                None
13270            }
13271            KeyCode::Char('e') => {
13272                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
13273                    self.export_counts = None;
13274                    self.export_modal.open(
13275                        self.original_file_format,
13276                        self.history_limit,
13277                        &self.theme,
13278                        self.original_file_delimiter,
13279                    );
13280                    // A name to start from, beside the source's rather than on it.
13281                    let stem = self.dataset_stem();
13282                    self.export_modal.suggest_path(&format!("{stem}-export"));
13283                    if let Some(state) = self.data_table_state.as_ref() {
13284                        self.export_modal.offer_source_file = state.can_name_source_files();
13285                        self.export_modal.nested_columns = state
13286                            .get_column_order()
13287                            .iter()
13288                            .filter_map(|name| state.schema().get(name))
13289                            .any(crate::nested_json::is_nested);
13290                        self.export_modal.avro_renames =
13291                            state.get_column_order().iter().any(|name| {
13292                                state
13293                                    .schema()
13294                                    .get(name)
13295                                    .is_some_and(|dtype| crate::avro_types::renames(name, dtype))
13296                            });
13297                    }
13298                    self.input_mode = InputMode::Export;
13299                }
13300                None
13301            }
13302            KeyCode::Char(' ') if event.is_press() => {
13303                if self.input_mode == InputMode::Normal {
13304                    self.open_inspector();
13305                }
13306                None
13307            }
13308            KeyCode::Char('g') if event.is_press() => {
13309                if self.input_mode == InputMode::Normal {
13310                    self.open_go_to_column();
13311                }
13312                None
13313            }
13314            KeyCode::Char('b') if event.is_press() => {
13315                if self.input_mode == InputMode::Normal {
13316                    self.open_format_picker();
13317                }
13318                None
13319            }
13320            KeyCode::Char('T') if event.is_press() => {
13321                if self.input_mode == InputMode::Normal {
13322                    self.open_table_picker();
13323                }
13324                None
13325            }
13326            KeyCode::Char('y') => {
13327                if self.input_mode == InputMode::Normal
13328                    && let Some(state) = self.data_table_state.as_ref()
13329                {
13330                    let columns = state.get_column_order().to_vec();
13331                    let context = copy_modal::CopyContext {
13332                        row_number: state.selected_display_row().unwrap_or(0),
13333                        view_rows: state.copy_view_df().map(|d| d.height()).unwrap_or(0),
13334                        view_cols: columns.len(),
13335                        total_rows: state.num_rows_if_valid(),
13336                    };
13337                    let current = state.current_column().map(str::to_string);
13338                    self.copy_modal.open(columns, current.as_deref(), context);
13339                    self.input_mode = InputMode::Copy;
13340                }
13341                None
13342            }
13343            _ => None,
13344        }
13345    }
13346
13347    /// Handle one event. A key that arrives while the app is busy is not acted on and
13348    /// not dropped either: it comes back as `Err(key)` for the caller to hold until the
13349    /// app is idle. The main loop ([`event_pump::EventPump`]) does exactly that;
13350    /// [`App::event`] is the same call for callers that have nowhere to hold a key.
13351    pub fn handle(&mut self, event: &AppEvent) -> EventOutcome {
13352        // Without the pump to offer it as typed, a pressed key is a key.
13353        if let AppEvent::Press(key) = event {
13354            return self.handle(&AppEvent::Key(*key));
13355        }
13356        if let AppEvent::Key(key) = event
13357            && self.is_busy()
13358            && !self.key_acts_while_busy(key)
13359        {
13360            return Err(*key);
13361        }
13362        let out = self.dispatch_event(event);
13363        // Not while this handler is returning a continuation. A follow-up is the rest of
13364        // the event just handled — the analysis sets `computing` and returns
13365        // `AnalysisChunk`, and the phase that chunk will spawn has not spawned — so
13366        // nothing holds the generation yet, and the errands would advance it out from
13367        // under the errand that is halfway through. They run after every event and are
13368        // built to wait; one more event is nothing to them.
13369        if out.is_none() {
13370            self.let_waiting_errands_in();
13371        }
13372        self.ensure_chart_data();
13373        self.home_score_search();
13374        // New rows on hand under an open find prompt: light up their matches.
13375        self.refresh_stale_live_matches();
13376        Ok(out)
13377    }
13378
13379    /// The errands that wait for the generation to be free, given their turn: after
13380    /// every event, and when a continuation's hold is let go.
13381    pub(crate) fn let_waiting_errands_in(&mut self) {
13382        // Columns a dataset's footers found while the user was inside a query are held
13383        // rather than dropped; this is where they get in, on the first event after the
13384        // view comes back to the data.
13385        if self.join_held_footers() {
13386            self.reread_after_the_footers_joined();
13387        }
13388        self.join_followed_fields();
13389        self.describe_ended_journal();
13390        // And the same turn for a re-read owed to a dataset whose footers could not be
13391        // read: it waits on the same work, and gets in the same way.
13392        self.reread_when_the_work_allows();
13393        self.collect_when_the_work_allows();
13394    }
13395
13396    pub fn event(&mut self, event: &AppEvent) -> Option<AppEvent> {
13397        self.handle(event).unwrap_or(None)
13398    }
13399
13400    /// True while chart data for the current view is being prepared off-thread — either
13401    /// its worker is running, or it is waiting its turn behind an orphaned worker that
13402    /// cannot be cancelled (see `ChartInflight::stale`). Either way the user is waiting
13403    /// on a computation and the throbber should say so.
13404    pub fn chart_preparing(&self) -> bool {
13405        match self.chart_inflight.as_ref() {
13406            Some(inflight) if !inflight.stale => true,
13407            Some(_) => self.chart_request_pending(),
13408            None => self.chart_settling(),
13409        }
13410    }
13411
13412    /// Whether the selection on screen is a step through the aggregates still waiting
13413    /// for the next step.
13414    fn chart_settling(&self) -> bool {
13415        self.chart_asked
13416            .as_ref()
13417            .and_then(|(_, until)| *until)
13418            .is_some_and(|until| std::time::Instant::now() < until)
13419    }
13420
13421    /// Whether the chart view wants data it does not have and cannot be told it will
13422    /// never get.
13423    fn chart_request_pending(&self) -> bool {
13424        if self.input_mode != InputMode::Chart || !self.chart_modal.active {
13425            return false;
13426        }
13427        ChartRequest::from_modal(&self.chart_modal)
13428            .is_some_and(|request| self.chart_cache.get(&request).is_none())
13429    }
13430
13431    /// Forget everything chart-related that belongs to the view or dataset on its way
13432    /// out: the cache, the handed-over slot, an export parked on data that is now never
13433    /// coming, and an export write still running (its file may still appear, but its
13434    /// result is ignored and `busy` is released). The preparation in flight is marked
13435    /// stale rather than forgotten: it cannot be cancelled, so it is waited for and its
13436    /// result discarded on arrival. Called when the chart view closes and whenever the
13437    /// dataset changes or is left for the home screen.
13438    fn reset_chart_state(&mut self) {
13439        self.chart_cache.clear();
13440        self.chart_asked = None;
13441        if let Some(inflight) = self.chart_inflight.as_mut() {
13442            inflight.stale = true;
13443            inflight
13444                .cancel
13445                .store(true, std::sync::atomic::Ordering::Relaxed);
13446        }
13447        // A failed export reopens its modal; it must not follow the user to the next
13448        // dataset.
13449        self.chart_export_modal.close();
13450        *self
13451            .pending_chart_result
13452            .lock()
13453            .unwrap_or_else(|e| e.into_inner()) = None;
13454        let writing = self
13455            .jobs
13456            .supersede(|job| matches!(job, Job::ChartExport { .. }));
13457        let waiting = self.chart_export_waiting.take().is_some();
13458        if writing || waiting {
13459            self.export_progress = None;
13460            self.status_message = None;
13461            self.busy = false;
13462        }
13463    }
13464
13465    /// What the chart being prepared is doing: an aggregate groups every row of the
13466    /// view, and says how many where the table knows.
13467    pub(crate) fn chart_status(&self) -> String {
13468        let aggregating = self
13469            .chart_inflight
13470            .as_ref()
13471            .filter(|i| !i.stale)
13472            .map(|i| i.request.aggregates())
13473            .or_else(|| ChartRequest::from_modal(&self.chart_modal).map(|r| r.aggregates()))
13474            .unwrap_or(false);
13475        if !aggregating {
13476            return "Preparing chart...".to_string();
13477        }
13478        match self
13479            .data_table_state
13480            .as_ref()
13481            .and_then(|s| s.num_rows_if_valid())
13482        {
13483            Some(rows) => format!("Grouping {} rows...", crate::discover::format_rows(rows)),
13484            None => "Grouping every row...".to_string(),
13485        }
13486    }
13487
13488    /// The series of the line or scatter chart on screen, by name, once prepared:
13489    /// its Y columns or its color groups.
13490    pub fn chart_names(&self) -> Option<Vec<String>> {
13491        let request = ChartRequest::from_modal(&self.chart_modal)?;
13492        match self.chart_cache.prepared(&request)? {
13493            ChartPrepared::XY(xy) => Some(xy.names.clone()),
13494            _ => None,
13495        }
13496    }
13497
13498    /// True when the chart cache holds the data for the modal's current selection.
13499    pub fn chart_data_ready(&self) -> bool {
13500        ChartRequest::from_modal(&self.chart_modal).is_some_and(|r| self.chart_cache.satisfies(&r))
13501    }
13502
13503    /// Start preparing the chart the modal currently asks for, unless the cache already
13504    /// has it, it is known to fail, or another preparation is still running (the newest
13505    /// selection is picked up when that one lands). Runs after every event, so a change
13506    /// of column or option is noticed as soon as it is made and render only ever draws.
13507    fn ensure_chart_data(&mut self) {
13508        const CHART_AGGREGATE_SETTLE: std::time::Duration = std::time::Duration::from_millis(150);
13509        if self.input_mode != InputMode::Chart || !self.chart_modal.active {
13510            return;
13511        }
13512        // What Every row costs, as the table counted it.
13513        self.chart_modal.view_rows = self
13514            .data_table_state
13515            .as_ref()
13516            .and_then(|state| state.num_rows_if_valid());
13517        let request = ChartRequest::from_modal(&self.chart_modal);
13518        if let Some(inflight) = self.chart_inflight.as_ref()
13519            && !request
13520                .as_ref()
13521                .is_some_and(|r| r.reads_as(&inflight.request))
13522        {
13523            // A count streaming a large view for a selection the cursor has moved
13524            // past would hold up the next chart for as long as it reads.
13525            inflight
13526                .cancel
13527                .store(true, std::sync::atomic::Ordering::Relaxed);
13528        }
13529        let Some(request) = request else {
13530            return;
13531        };
13532        // Stepping none, count, distinct, sum, mean grouped every row at each step, and
13533        // drew each: a step waits a moment for the next, and only where it stops is
13534        // prepared. A Wake when the wait ends prepares it.
13535        let settle = match self.chart_asked.take() {
13536            Some((asked, until)) if asked == request => until,
13537            Some((asked, _)) if request.steps_aggregate_from(&asked) => {
13538                let tx = self.events.clone();
13539                std::thread::spawn(move || {
13540                    std::thread::sleep(CHART_AGGREGATE_SETTLE);
13541                    let _ = tx.send(AppEvent::Wake);
13542                });
13543                Some(std::time::Instant::now() + CHART_AGGREGATE_SETTLE)
13544            }
13545            _ => None,
13546        };
13547        self.chart_asked = Some((request.clone(), settle));
13548        if self.chart_cache.get(&request).is_some() {
13549            self.chart_cache.touch(&request, self.chart_modal.log_scale);
13550            // A cached chart's colors were counted with it.
13551            if let Some(colors) = request
13552                .spec
13553                .encoding
13554                .color
13555                .field
13556                .as_deref()
13557                .and_then(|c| self.chart_cache.colors(c))
13558            {
13559                self.chart_modal.color_counts = Some(colors.clone());
13560            }
13561            return;
13562        }
13563        if self.chart_inflight.is_some() || self.chart_settling() {
13564            return;
13565        }
13566        let Some(state) = self.data_table_state.as_ref() else {
13567            return;
13568        };
13569        // Unsorted: the rows a chart draws do not depend on the table's order, a line
13570        // is drawn in X order anyway, and a sort would make a sampled read read it all.
13571        // First and last are the order's: they read the view as sorted.
13572        let lf = if request.sorted {
13573            state.lf().clone()
13574        } else {
13575            state.analysis_lf()
13576        };
13577        let schema = state.schema().clone();
13578        let dataset = Some(state.len_generation());
13579        let sampling = chart_data::ChartSampling {
13580            // None for an aggregate: it reads every row.
13581            limit: request.row_limit,
13582            known_total: state.num_rows_if_valid(),
13583            seed: self.analysis_modal.sample.seed,
13584            streaming: self.app_config.performance.streaming,
13585            full_passes: !state.is_remote_source(),
13586            held: self.chart_cache.held_rows(dataset),
13587            cancel: Arc::default(),
13588        };
13589        self.chart_inflight = Some(ChartInflight {
13590            dataset,
13591            request: request.clone(),
13592            stale: false,
13593            cancel: Arc::clone(&sampling.cancel),
13594        });
13595        let slot = self.pending_chart_result.clone();
13596        let tx = self.events.clone();
13597        self.runtime.spawn_blocking(move || {
13598            // A panic in the preparation must still report back: without the event the
13599            // in-flight record would stand for the rest of the session and every later
13600            // selection would be refused.
13601            let result = logging::catch_panic(|| request.prepare(&lf, &schema, &sampling))
13602                .unwrap_or_else(|_| Err(color_eyre::eyre::eyre!("Chart preparation panicked")))
13603                .map_err(|e| crate::error_display::user_message_from_report(&e, None));
13604            *slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(result);
13605            let _ = tx.send(AppEvent::BackgroundChartReady);
13606        });
13607    }
13608
13609    fn dispatch_event(&mut self, event: &AppEvent) -> Option<AppEvent> {
13610        self.debug.num_events += 1;
13611
13612        match event {
13613            AppEvent::Key(key) => {
13614                // Leaving while standard input is still being recorded asks first.
13615                if let Some(leaving) = self.leaves(key)
13616                    && !self.confirmation_modal.active
13617                    && self.recording().is_some_and(|spool| spool.live())
13618                {
13619                    self.ask_about_recording(leaving);
13620                    return None;
13621                }
13622                self.key(key)
13623            }
13624            AppEvent::Open(paths, options) => {
13625                if paths.is_empty() {
13626                    return Some(AppEvent::Crash("No paths provided".to_string()));
13627                }
13628                // Home is now in the stack, so q pops back to it. Never unset:
13629                // a reread from the table (H) is not a new place.
13630                if self.input_mode == InputMode::Home {
13631                    self.opened_from_home = true;
13632                }
13633                // `az://container/path` and its kin name no account; where they were
13634                // typed, or the config, does.
13635                #[cfg(feature = "cloud")]
13636                let expanded = match paths
13637                    .iter()
13638                    .map(|p| {
13639                        crate::cloud_sources::expand_azure_short_url(
13640                            p,
13641                            &self.app_config.cloud,
13642                            self.home.browsing.as_deref(),
13643                        )
13644                    })
13645                    .collect::<std::result::Result<Vec<_>, _>>()
13646                {
13647                    Ok(expanded) => expanded,
13648                    Err(message) => return Some(AppEvent::Crash(message)),
13649                };
13650                #[cfg(feature = "cloud")]
13651                if &expanded != paths {
13652                    return Some(AppEvent::Open(expanded, options.clone()));
13653                }
13654                // Asks the filesystem for the size the loading screen shows, and whether
13655                // the path is there to be a recent.
13656                let mut request =
13657                    loading::OpenRequest::named(paths.clone(), options.clone(), &self.formats);
13658                request.warn_in_memory_above = self.app_config.read.memory_warning();
13659                self.begin_new_dataset();
13660                let step = self.loading.open(request);
13661                self.run_load_step(step)
13662            }
13663            AppEvent::OpenLazyFrame(lf, options) => {
13664                self.begin_new_dataset();
13665                let step = self.loading.open_frame((**lf).clone(), options.clone());
13666                self.run_load_step(step)
13667            }
13668            AppEvent::HomeListingReady {
13669                generation,
13670                listing,
13671                known,
13672                visits,
13673                newest,
13674                folds,
13675            } => {
13676                // Only the current listing's answer clears the flag: a stale one landing
13677                // first said nothing was in flight while the listing for where the user
13678                // is still ran. Every refresh asks again, so the newest always answers.
13679                if *generation == self.home_generation {
13680                    self.home.listing_in_flight = false;
13681                }
13682                // Read fresh from the cache, so true whichever listing carried them:
13683                // the facts fill in rows the recursive search finds the same way, and
13684                // only the first listing after entering home carries the folds.
13685                self.home.known = known.clone();
13686                self.home.visits = visits.clone();
13687                self.home.newest_recent = newest.clone();
13688                if let Some(folds) = folds {
13689                    self.home.folds = folds.clone();
13690                }
13691                // A listing from a superseded request describes somewhere the user has
13692                // already left.
13693                if *generation != self.home_generation {
13694                    return None;
13695                }
13696                self.home.apply_listing((**listing).clone());
13697                // Probes are chosen from the sections, so they can only be started
13698                // once those exist — asking before the listing lands finds nothing.
13699                self.spawn_home_probes();
13700                #[cfg(feature = "cloud")]
13701                self.spawn_cloud_discovery();
13702                self.request_home_measurements();
13703                self.request_home_classifications();
13704                None
13705            }
13706            AppEvent::HomeListingFailed => {
13707                // The rows already listed stay. The panic is flashed as a raw worker's.
13708                self.home.listing_in_flight = false;
13709                None
13710            }
13711            AppEvent::HomeMeasured { measured, done } => {
13712                for (path, m) in measured {
13713                    self.home.enriched.insert(path.clone(), m.clone());
13714                }
13715                self.home.apply_measurements();
13716                if *done {
13717                    self.home.measure_in_flight = false;
13718                    self.request_home_measurements();
13719                }
13720                None
13721            }
13722            AppEvent::HomeSized { path, measured } => {
13723                // Only the size: a measurement that landed meanwhile keeps the rest.
13724                match self.home.enriched.get_mut(path) {
13725                    Some(known) => known.size = measured.size,
13726                    None => {
13727                        self.home.enriched.insert(path.clone(), measured.clone());
13728                    }
13729                }
13730                self.home.apply_measurements();
13731                None
13732            }
13733            AppEvent::HomeWebGone { path, gone } => {
13734                self.home.web_gone.insert(path.clone(), gone.clone());
13735                None
13736            }
13737            AppEvent::HomeClassified { measured, done } => {
13738                // Kept even when the listing has been rebuilt since it was asked for. A
13739                // probe or a cloud peek landing rebuilds it, and a Recent section full of
13740                // buckets lands several in a row: dropping the answer each time left a
13741                // share's rows unlabeled for as long as the cloud kept answering.
13742                for (path, m) in measured {
13743                    self.home.enriched.insert(path.clone(), m.clone());
13744                }
13745                // Nothing re-sorts. `apply_measurements` writes the kind into the row
13746                // where it already is, which is the whole reason a kind is allowed to
13747                // arrive after the row was drawn: a listing that reshuffled itself
13748                // under the cursor while it filled in would be worse than a late
13749                // label.
13750                self.home.apply_measurements();
13751                // The next batch is chosen from the viewport as it is now, so a page
13752                // that scrolled past four hundred rows while this one was out asks
13753                // about the forty it landed on, not the four hundred it left behind.
13754                if *done {
13755                    self.home.classify_in_flight = false;
13756                    self.request_home_classifications();
13757                }
13758                None
13759            }
13760            AppEvent::HomePathListed { listing } => {
13761                // Kept only for the directory still being typed: a listing for one the
13762                // user has typed past would offer names from somewhere else.
13763                if self.home.path_input_active
13764                    && home::typed_dir(&self.home.path_input) == listing.dir
13765                {
13766                    self.home.path_listing = Some((**listing).clone());
13767                    if self.home.path_pick.is_none() {
13768                        self.home.pick_first_path();
13769                    }
13770                }
13771                None
13772            }
13773            AppEvent::HomePathCompleted {
13774                generation,
13775                typed,
13776                completed,
13777                candidates,
13778            } => {
13779                // Discard if the user has typed since asking: completing onto a
13780                // different string would scramble what they are in the middle of.
13781                if *generation != self.home_generation || &self.home.path_input != typed {
13782                    return None;
13783                }
13784                if *candidates == 0 {
13785                    self.home.status = Some("No such path".to_string());
13786                } else {
13787                    self.home.status = None;
13788                    if *candidates > 1 {
13789                        self.flash_note(format!("{candidates} matches"));
13790                    }
13791                    self.home.path_input = completed.clone();
13792                    self.home.pick_first_path();
13793                }
13794                None
13795            }
13796            AppEvent::HomePreviewReady {
13797                path,
13798                stamp,
13799                read_at,
13800                rows,
13801                prepared,
13802            } => {
13803                let prepared = prepared.lock().ok().and_then(|mut p| p.take());
13804                // The columns came with the rows: the pane lists them, for a CSV too.
13805                if let Some(prepared) = &prepared {
13806                    let schema = prepared
13807                        .state
13808                        .schema()
13809                        .iter()
13810                        .map(|(name, dtype)| (name.to_string(), dtype.clone()))
13811                        .collect();
13812                    self.home_schema_cache.insert(path.clone(), Some(schema));
13813                }
13814                let prepared = prepared.filter(|_| read_at.is_some());
13815                self.home_previews.landed(
13816                    path.clone(),
13817                    *stamp,
13818                    read_at.unwrap_or(*stamp),
13819                    rows.clone(),
13820                    prepared,
13821                );
13822                None
13823            }
13824            AppEvent::HomeSchemaReady {
13825                generation,
13826                path,
13827                preview,
13828            } => {
13829                self.home_schema_inflight.retain(|p| p != path);
13830                // A preview's columns are not taken back by a metadata read that had none.
13831                let known = self
13832                    .home_schema_cache
13833                    .get(path)
13834                    .is_some_and(Option::is_some);
13835                if *generation == self.home_generation && (preview.is_some() || !known) {
13836                    self.home_schema_cache.insert(path.clone(), preview.clone());
13837                }
13838                None
13839            }
13840            AppEvent::HomeSearchBatch {
13841                generation,
13842                root,
13843                found,
13844                scanned,
13845            } => {
13846                // Results from a walk that a later navigation superseded describe a
13847                // place the user has left. The walk is abandoned, not cancelled, so
13848                // late batches are expected rather than exceptional. A refresh of the
13849                // same place supersedes nothing: its end dropped kept it running.
13850                if *generation == self.home_search_generation {
13851                    self.home.search_batch(root, found.clone(), *scanned);
13852                }
13853                None
13854            }
13855            AppEvent::HomeSearchScored { epoch, matches } => {
13856                // A scoring that died is not asked again: the next would die the same
13857                // way, and the matches already listed stand.
13858                if let Some(matches) = matches {
13859                    self.home.search_scored(*epoch, (**matches).clone());
13860                }
13861                None
13862            }
13863            AppEvent::HomeSearchDone {
13864                generation,
13865                root,
13866                scanned,
13867                limited,
13868            } => {
13869                if *generation == self.home_search_generation {
13870                    self.home.search_finished(root, *scanned, limited.clone());
13871                }
13872                self.home_search_inflight = false;
13873                // A walk abandoned by a browse held up the one the filter now asks for.
13874                if !self.home.filter.is_empty() && self.home.search.root.is_none() {
13875                    self.spawn_home_search();
13876                }
13877                None
13878            }
13879            #[cfg(feature = "cloud")]
13880            AppEvent::HomeCloudSources { sources } => {
13881                self.home.cloud = sources.clone();
13882                self.home_refresh();
13883                None
13884            }
13885            #[cfg(feature = "cloud")]
13886            AppEvent::HomeCloudListed {
13887                id,
13888                buckets,
13889                details,
13890                failure,
13891                listed_at,
13892            } => {
13893                if let Some(source) = self.home.cloud.iter_mut().find(|s| &s.id == id) {
13894                    source.refreshing = false;
13895                    for (place, lines) in details {
13896                        source.place_details.insert(place.clone(), lines.clone());
13897                    }
13898                    match failure {
13899                        // A refresh that failed keeps what the last one found: stale
13900                        // buckets are more use than none, and the row says it failed.
13901                        Some((short, detail)) => {
13902                            for bucket in buckets {
13903                                if !source.buckets.contains(bucket) {
13904                                    source.buckets.push(bucket.clone());
13905                                }
13906                            }
13907                            source.status = home::CloudStatus::Failed {
13908                                short: short.clone(),
13909                                detail: detail.clone(),
13910                            };
13911                        }
13912                        None => {
13913                            source.buckets = buckets.clone();
13914                            source.status = home::CloudStatus::Listed;
13915                            source.listed_at = Some(*listed_at);
13916                        }
13917                    }
13918                }
13919                self.home_refresh();
13920                None
13921            }
13922            AppEvent::HomeNarrowed {
13923                dir,
13924                prefix,
13925                listed,
13926            } => {
13927                // Only the request out now: one replaced by a later key may still
13928                // answer, after the later one, and put back the shorter prefix.
13929                let asked = self
13930                    .home_narrowing
13931                    .as_ref()
13932                    .is_some_and(|(d, p, _)| d == dir && p == prefix);
13933                if asked {
13934                    self.home_narrowing = None;
13935                }
13936                // Only while it is still where the user is and what the filter asks.
13937                let wanted = asked
13938                    && self.home.browsing.as_ref() == Some(dir)
13939                    && !self.home.filter.is_empty();
13940                if let (Some((rows, truncated)), true) = (listed, wanted) {
13941                    self.home.narrowed = Some(home::Narrowed {
13942                        dir: dir.clone(),
13943                        prefix: prefix.clone(),
13944                        rows: rows.clone(),
13945                        truncated: *truncated,
13946                    });
13947                    self.home_refresh();
13948                }
13949                None
13950            }
13951            AppEvent::HomeProbeCancelled { root } => {
13952                self.home_probes_inflight.retain(|p| p != root);
13953                self.home_listing_cancels.remove(root);
13954                self.home.listing_so_far.remove(root);
13955                // Come back to after it had stopped: listed afresh.
13956                if self.home.browsing.as_ref() == Some(root) {
13957                    self.home_refresh();
13958                }
13959                None
13960            }
13961            AppEvent::HomeProbeFailed { root, message } => {
13962                self.home_probes_inflight.retain(|p| p != root);
13963                self.home_listing_cancels.remove(root);
13964                self.home.probe_failed(root.clone());
13965                self.home.probe_errors.insert(root.clone(), message.clone());
13966                self.home_refresh();
13967                None
13968            }
13969            AppEvent::HomeProbeProgress { root, rows } => {
13970                // Only while that listing is still out: a late batch must not paint
13971                // over the whole answer.
13972                if self.home_probes_inflight.contains(root) && !self.home.probed.contains_key(root)
13973                {
13974                    self.home.listing_so_far.insert(root.clone(), rows.clone());
13975                    self.home_refresh();
13976                }
13977                None
13978            }
13979            AppEvent::HomeProbeReady {
13980                root,
13981                rows,
13982                cut_short,
13983            } => {
13984                // Give the slot back. The cap exists to bound threads wedged on a dead
13985                // `hard` mount, which never send this event and so keep their slot for
13986                // good — a probe that answered is not one of those. Without this the
13987                // list only grows, and after MAX_CONCURRENT_PROBES roots no further
13988                // root is ever probed for the rest of the session.
13989                self.home_probes_inflight.retain(|p| p != root);
13990                self.home_listing_cancels.remove(root);
13991                let landed = rows.is_some();
13992                match rows {
13993                    Some(rows) => self.home.probe_ready(root.clone(), rows.clone()),
13994                    None => self.home.probe_failed(root.clone()),
13995                }
13996                if *cut_short {
13997                    self.home.cut_short.insert(root.clone());
13998                }
13999                // A filter typed while it was listing asks the server too.
14000                #[cfg(feature = "cloud")]
14001                if *cut_short && !self.home.filter.is_empty() {
14002                    self.narrow_cloud_listing();
14003                }
14004                // An account read with its keys because the sign-in has no data role
14005                // says so beside the account.
14006                #[cfg(feature = "cloud")]
14007                if let Some((account, _, _)) = source::azure_parts(&root.to_string_lossy())
14008                    && crate::azure::remembered_key(&account).is_some()
14009                {
14010                    for source in &mut self.home.cloud {
14011                        let place = source
14012                            .buckets
14013                            .iter()
14014                            .find(|b| home::cloud_account(b).is_some_and(|(_, a)| a == account))
14015                            .cloned();
14016                        if let Some(place) = place {
14017                            let lines = source.place_details.entry(place).or_default();
14018                            if !lines.iter().any(|(k, _)| k == "access") {
14019                                lines.push(("access".to_string(), "access key".to_string()));
14020                            }
14021                        }
14022                    }
14023                }
14024                // Rebuild so the listing picks the result up; the probe is the only
14025                // thing that ever reads a remote root.
14026                self.home_refresh();
14027                // And then ask about the rows it brought. After the rebuild, never
14028                // before: the picker reads `visible()`, which is written by the
14029                // rebuild, so a peek asked between `probe_ready` and here looks at the
14030                // previous listing and finds nothing in it to ask about.
14031                //
14032                // Asked here at all because a listing that lands while the cursor is
14033                // already where it will stay may draw no further frame, and the frame
14034                // is what otherwise notices.
14035                #[cfg(feature = "cloud")]
14036                if landed {
14037                    self.peek_cloud_directories();
14038                }
14039                #[cfg(not(feature = "cloud"))]
14040                let _ = landed;
14041                None
14042            }
14043            AppEvent::HomeCloudKinds { kinds, failed } => {
14044                for directory in failed {
14045                    self.home.peeking.remove(directory);
14046                    self.home.peek_failed.insert(directory.clone());
14047                }
14048                let roots: Vec<PathBuf> = self.home.probed.keys().cloned().collect();
14049                for (directory, kind) in kinds {
14050                    // Answered: out of the in-flight set and into the one the rows are
14051                    // labelled from. Every directory asked about comes back, so nothing
14052                    // stays in `peeking` and nothing is asked twice.
14053                    self.home.peeking.remove(directory);
14054                    self.home
14055                        .cloud_kinds
14056                        .insert(directory.clone(), kind.clone());
14057                }
14058                for root in roots {
14059                    self.home.apply_cloud_kinds(&root);
14060                }
14061                self.home_refresh();
14062                None
14063            }
14064            AppEvent::Resize(_cols, _rows) => {
14065                // No work here: the next render sets visible_rows and flips needs_recollect,
14066                // which the main loop turns into an async collect against the correct size.
14067                None
14068            }
14069            AppEvent::Collect => {
14070                self.spawn_async_collect(Self::LOADING_BUFFER);
14071                None
14072            }
14073            AppEvent::DoScrollDown => self.handle_scroll(|s| s.page_down()),
14074            AppEvent::DoScrollUp => self.handle_scroll(|s| s.page_up()),
14075            AppEvent::DoScrollNext => self.handle_scroll(|s| s.select_next()),
14076            AppEvent::DoScrollPrev => self.handle_scroll(|s| s.select_previous()),
14077            AppEvent::DoScrollEnd => self.handle_scroll(|s| s.scroll_to_end()),
14078            AppEvent::DoScrollHome => self.handle_scroll(|s| s.scroll_to_start()),
14079            AppEvent::DoScrollHalfDown => self.handle_scroll(|s| s.half_page_down()),
14080            AppEvent::DoScrollHalfUp => self.handle_scroll(|s| s.half_page_up()),
14081            AppEvent::GoToLine(n) => {
14082                let n = *n;
14083                // Past the lines indexed so far: gone to once they all are.
14084                if let Some(state) = self.data_table_state.as_ref()
14085                    && state.indexing().is_some()
14086                    && (n >= state.num_rows()
14087                        || state.changes_rows()
14088                        || !state.view_sort_columns().is_empty()
14089                        || !state.view_sort_ascending())
14090                {
14091                    self.goto_when_indexed = Some((self.dataset_generation, n));
14092                    self.status_message = Some(Self::INDEXING_FOR_ROW.to_string());
14093                    self.busy = false;
14094                    return None;
14095                }
14096                self.handle_scroll(|s| s.scroll_to_row_centered(n))
14097            }
14098            AppEvent::AnalysisChunk => {
14099                // Binary columns are stubbed by the source: their blobs are never read
14100                // for analysis (multi-GB blobs across partitions can exhaust memory).
14101                let (source, known_total) = match &self.data_table_state {
14102                    Some(state) => self.sample_source(state),
14103                    None => {
14104                        self.analysis_computation = None;
14105                        self.analysis_modal.computing = None;
14106                        self.busy = false;
14107                        return None;
14108                    }
14109                };
14110                let comp = self.analysis_computation.take()?;
14111                if comp.df.is_none() {
14112                    let sample = self.analysis_modal.sample.clone();
14113                    let streaming = self.app_config.performance.streaming;
14114                    self.spawn_job(
14115                        Job::Analysis(jobs::AnalysisRun::default()),
14116                        Some("Running analysis..."),
14117                        move |_| {
14118                            let results = source
14119                                .cut(&sample.scope)
14120                                .and_then(|lf| {
14121                                    crate::statistics::compute_describe_from_lazy(
14122                                        &lf,
14123                                        known_total,
14124                                        &sample,
14125                                        streaming,
14126                                    )
14127                                })
14128                                .map_err(|e| format!("{e}"))?;
14129                            Ok(Answer::Described(results))
14130                        },
14131                    );
14132                }
14133                None
14134            }
14135            AppEvent::AnalysisDistributionCompute => {
14136                if let Some(state) = &self.data_table_state {
14137                    let (source, known_total) = self.sample_source(state);
14138                    let sample = self.analysis_modal.sample.clone();
14139                    let streaming = self.app_config.performance.streaming;
14140                    self.spawn_job(
14141                        Job::Analysis(jobs::AnalysisRun::default()),
14142                        Some("Analyzing distributions..."),
14143                        move |_| {
14144                            let options = crate::statistics::ComputeOptions {
14145                                include_distribution_info: true,
14146                                include_distribution_analyses: true,
14147                                include_correlation_matrix: false,
14148                                include_skewness_kurtosis_outliers: true,
14149                                polars_streaming: streaming,
14150                            };
14151                            let results = source
14152                                .cut(&sample.scope)
14153                                .and_then(|lf| {
14154                                    crate::statistics::compute_statistics_for_sample(
14155                                        &lf,
14156                                        &sample,
14157                                        known_total,
14158                                        options,
14159                                    )
14160                                })
14161                                .map_err(|e| format!("{e}"))?;
14162                            Ok(Answer::Distributions(results))
14163                        },
14164                    );
14165                } else {
14166                    self.analysis_modal.computing = None;
14167                    self.busy = false;
14168                }
14169                None
14170            }
14171            AppEvent::AnalysisCorrelationCompute => {
14172                if let Some(state) = &self.data_table_state {
14173                    let (source, known_total) = self.sample_source(state);
14174                    let streaming = state.polars_streaming();
14175                    let sample = self.analysis_modal.sample.clone();
14176                    let seed = sample.seed;
14177                    self.spawn_job(
14178                        Job::Analysis(jobs::AnalysisRun::default()),
14179                        Some("Computing correlation matrix..."),
14180                        move |_| {
14181                            // Only the numeric columns: nothing else is correlated, and on a
14182                            // wide table the rest is most of what a full read would hold.
14183                            let result = source
14184                                .cut(&sample.scope)
14185                                .and_then(|lf| {
14186                                    let schema = lf.clone().collect_schema()?;
14187                                    let numeric: Vec<polars::prelude::Expr> = schema
14188                                        .iter()
14189                                        .filter(|(_, dtype)| dtype.is_numeric())
14190                                        .map(|(name, _)| col(name.clone()))
14191                                        .collect();
14192                                    crate::sampling::read(
14193                                        &lf.select(numeric),
14194                                        &sample,
14195                                        known_total,
14196                                        streaming,
14197                                    )
14198                                })
14199                                .map(|rows| {
14200                                    let matrix =
14201                                        crate::statistics::compute_correlation_matrix(&rows.df)
14202                                            .ok();
14203                                    crate::statistics::AnalysisResults {
14204                                        column_statistics: vec![],
14205                                        total_rows: rows.total_rows,
14206                                        sample_size: rows.sample_size,
14207                                        per_value: rows.per_value.map(|per_value| per_value.kept),
14208                                        sample_seed: seed,
14209                                        correlation_matrix: matrix,
14210                                        distribution_analyses: vec![],
14211                                    }
14212                                });
14213                            let results = result.map_err(|e| format!("{e}"))?;
14214                            Ok(Answer::Correlations(results))
14215                        },
14216                    );
14217                } else {
14218                    self.analysis_modal.computing = None;
14219                    self.busy = false;
14220                }
14221                None
14222            }
14223            AppEvent::AnalysisDataQualityCompute => {
14224                // The plan Run committed; Setup's Run is the only way here.
14225                if let Some(state) = &self.data_table_state {
14226                    let plan = self.analysis_modal.data_quality_plan.clone();
14227                    let source_scope = plan.scope.uses_source();
14228                    let (lf, source, cached_rows) = if source_scope {
14229                        let (lf, source) = state.data_quality_source_scan();
14230                        (lf, source, None)
14231                    } else {
14232                        let ordered = matches!(
14233                            plan.scope,
14234                            data_quality::QualityScope::FirstRows(_)
14235                                | data_quality::QualityScope::ViewRows { .. }
14236                        );
14237                        let (lf, source) = state.data_quality_scan(ordered);
14238                        let rows = state.num_rows_if_valid().map(|rows| match &plan.scope {
14239                            data_quality::QualityScope::CurrentView => rows,
14240                            data_quality::QualityScope::FirstRows(limit) => rows.min(*limit),
14241                            data_quality::QualityScope::ViewRows { start, end } => {
14242                                rows.min(*end).saturating_sub(start.saturating_sub(1))
14243                            }
14244                            _ => unreachable!(),
14245                        });
14246                        (lf, source, rows)
14247                    };
14248                    let streaming = state.polars_streaming();
14249                    // An audio file's signal checks read its samples whole: a full run's.
14250                    let audio = (plan.compute == data_quality::QualityCompute::Full)
14251                        .then(|| state.window_for_quality(&plan.scope))
14252                        .flatten()
14253                        .and_then(crate::audio::recording);
14254                    let view_generation = state.len_generation();
14255                    let dataset_generation = self.dataset_generation;
14256                    let kept_entry = self.kept_quality_entry(&plan.sample());
14257                    let kept = kept_entry.map(|kept| kept.rows.clone());
14258                    // A sampled run on rows already read is labeled as their read was:
14259                    // the file may have changed since, and these rows did not.
14260                    let kept_source = kept_entry
14261                        .filter(|_| plan.compute == data_quality::QualityCompute::Sample)
14262                        .map(|kept| kept.source.clone());
14263                    let mut identity = self.quality_source_identity(state, &plan.scope);
14264                    let copy_job = match self.quality_copy_plan(&plan) {
14265                        data_quality::CopyPlan::Kept { .. } => self
14266                            .quality_copy_kept()
14267                            .cloned()
14268                            .map_or(QualityCopyJob::Source, QualityCopyJob::Kept),
14269                        data_quality::CopyPlan::Fetch { .. } => match state.remote_objects() {
14270                            Some(objects) => QualityCopyJob::Fetch {
14271                                objects,
14272                                root: self.quality_copies_root(),
14273                            },
14274                            None => QualityCopyJob::Source,
14275                        },
14276                        _ => QualityCopyJob::Source,
14277                    };
14278                    #[cfg(feature = "cloud")]
14279                    let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
14280                    // Only a confirmed full scan pays to read the values a type
14281                    // conflict hides, and only its access plan promised the read.
14282                    let mut source = source;
14283                    if plan.compute == data_quality::QualityCompute::Full
14284                        && let Some(source) = source.as_mut()
14285                    {
14286                        source.conflict_scan = state.quality_conflict_scan();
14287                    }
14288                    // Each stage the worker enters comes back as the job's progress, so a
14289                    // cancelled run's stages are dropped. The watch is the job's: Esc
14290                    // stops the run through its record.
14291                    let started = self.start_job(
14292                        Job::Analysis(jobs::AnalysisRun::default()),
14293                        Some("Profiling data quality..."),
14294                    );
14295                    let ticket = started.ticket();
14296                    let phases = self.events.clone();
14297                    let watch = data_quality::QualityWatch::new(move |phase| {
14298                        let _ = phases.send(AppEvent::JobProgress {
14299                            ticket,
14300                            progress: Progress::QualityPhase(phase),
14301                        });
14302                    });
14303                    if let Some(progress) = self.analysis_modal.computing.as_mut() {
14304                        progress.read = Some(watch.read().clone());
14305                    }
14306                    if let Some(Job::Analysis(run)) = self.jobs.job_mut(ticket) {
14307                        run.watch = Some(watch.clone());
14308                    }
14309                    started.run(&self.runtime, move |worker| {
14310                        // A stat of a local file as the run begins, not a read.
14311                        match kept_source {
14312                            Some(source) => identity = source,
14313                            None => identity.stat(),
14314                        }
14315                        let lf = if source_scope {
14316                            data_quality::prepare_source_quality_scan(lf, source.as_ref())
14317                                .map_err(|error| format!("{error}"))?
14318                        } else {
14319                            lf
14320                        };
14321                        let lf =
14322                            data_quality::apply_quality_scope(lf, &plan.scope, source.as_ref())
14323                                .map_err(|error| format!("{error}"))?;
14324                        // Held to the end of the run: the copy stays on disk while its
14325                        // passes read it, released or not.
14326                        let fetch = |objects: &[crate::local_copy::RemoteObject], root: &Path| {
14327                            #[cfg(feature = "cloud")]
14328                            {
14329                                Self::fetch_quality_copy(
14330                                    objects,
14331                                    root,
14332                                    &cloud,
14333                                    &runtime,
14334                                    watch.read(),
14335                                )
14336                            }
14337                            #[cfg(not(feature = "cloud"))]
14338                            {
14339                                let _ = (objects, root);
14340                                Err(color_eyre::eyre::eyre!("Built without cloud support"))
14341                            }
14342                        };
14343                        let kept_copy = |copy: Option<Arc<crate::local_copy::LocalCopy>>| {
14344                            worker.send(AppEvent::BackgroundQualityCopyKept {
14345                                dataset_generation,
14346                                copy,
14347                            });
14348                        };
14349                        let (lf, held) =
14350                            Self::quality_scope_on_copy(lf, copy_job, &watch, fetch, kept_copy)
14351                                .map_err(|error| format!("{error}"))?;
14352                        let (results, rows) = crate::data_quality::compute_data_quality_watched(
14353                            &lf,
14354                            cached_rows,
14355                            &plan,
14356                            source.as_ref(),
14357                            streaming,
14358                            kept.as_deref(),
14359                            &watch,
14360                        );
14361                        let results = match (results, audio) {
14362                            (Ok(mut results), Some(audio)) => {
14363                                crate::data_quality::add_signal_observations(
14364                                    &mut results,
14365                                    &audio,
14366                                    &watch,
14367                                )
14368                                .map(|()| results)
14369                            }
14370                            (results, _) => results,
14371                        };
14372                        // Let go before the answer goes out: a `d` handled as soon
14373                        // as it lands must find the app's handle the last one.
14374                        drop(held);
14375                        let kept = rows.map(|rows| KeptQualitySample {
14376                            dataset_generation,
14377                            view_generation,
14378                            sample: plan.sample(),
14379                            rows: std::sync::Arc::new(rows),
14380                            source: identity.clone(),
14381                        });
14382                        match results {
14383                            Ok(mut results) => {
14384                                results.source = Some(Box::new(identity));
14385                                Ok(Answer::DataQuality {
14386                                    results: Box::new(results),
14387                                    kept,
14388                                    plan: Box::new(plan),
14389                                })
14390                            }
14391                            Err(error) => {
14392                                // Stopped after the sample was read: the read is kept.
14393                                if let Some(kept) = kept {
14394                                    worker.send(AppEvent::BackgroundQualitySampleKept { kept });
14395                                }
14396                                Err(format!("{error}"))
14397                            }
14398                        }
14399                    });
14400                } else {
14401                    self.analysis_modal.computing = None;
14402                    self.busy = false;
14403                }
14404                None
14405            }
14406            AppEvent::BackgroundLenReady {
14407                len_generation,
14408                num_rows,
14409                file_row_groups,
14410            } => {
14411                if self.len_count_inflight == Some(*len_generation) {
14412                    self.len_count_inflight = None;
14413                }
14414                if self.len_count_failed == Some(*len_generation) {
14415                    self.len_count_failed = None;
14416                }
14417                // A count of the view a running query replaced goes back with it.
14418                if let Some(run) = self.query_running.as_mut() {
14419                    run.rollback.count_landed(
14420                        *len_generation,
14421                        *num_rows,
14422                        file_row_groups.as_deref(),
14423                    );
14424                    if run.len_count_inflight == Some(*len_generation) {
14425                        run.len_count_inflight = None;
14426                    }
14427                }
14428                // Apply the exact total only if the data hasn't changed since the count
14429                // was spawned. This runs independently of the buffer paint (which has
14430                // usually already rendered), so it just corrects the scrollbar/total —
14431                // no busy state, no re-collect.
14432                if let Some(state) = self.data_table_state.as_mut()
14433                    && state.count_landed(*len_generation, *num_rows, file_row_groups.as_deref())
14434                {
14435                    // End was pressed before there was an end to go to.
14436                    if self.end_after_count == Some(*len_generation) {
14437                        self.end_after_count = None;
14438                        self.status_message = None;
14439                        return self.jump_key(AppEvent::DoScrollEnd);
14440                    }
14441                } else if self.end_after_count == Some(*len_generation) {
14442                    // This is the count End was waiting on, and it answers a frame that
14443                    // is gone — a join landed underneath it and took a fresh
14444                    // `len_generation` past it. Left here the flag is stranded on a
14445                    // generation nothing will ever match: the next count to fail for any
14446                    // reason would speak in its name. So it is retired, and the status
14447                    // it put up comes down with it.
14448                    //
14449                    // Retired, not asked again of the frame that is here. That frame can
14450                    // belong to a dataset the user opened since — `end_after_count` names
14451                    // a `len_generation`, which says nothing about which dataset — and
14452                    // re-asking made the *new* dataset scroll itself to the end on the
14453                    // strength of a key pressed in the old one. A jump the frame change
14454                    // swallowed is a jump the user can make again; a jump that arrives on
14455                    // its own, in a directory they did not press it in, is not.
14456                    self.retire_the_end_that_was_waiting();
14457                }
14458                self.remember_a_downloads_shape();
14459                None
14460            }
14461            AppEvent::FramePainted => {
14462                self.frame_painted();
14463                None
14464            }
14465            AppEvent::BackgroundLenFailed { len_generation } => {
14466                if self.len_count_inflight == Some(*len_generation) {
14467                    self.len_count_inflight = None;
14468                }
14469                if self.count_after_stop.take() == Some(*len_generation) {
14470                    self.count_exactly();
14471                    return None;
14472                }
14473                if let Some(run) = self.query_running.as_mut()
14474                    && run.len_count_inflight == Some(*len_generation)
14475                {
14476                    run.len_count_inflight = None;
14477                    run.len_count_failed = Some(*len_generation);
14478                }
14479                // Mark this generation's count as failed so the row count renders as "?"
14480                // instead of a misleading provisional total. Before the End handling
14481                // below: this is about the count, not about who was waiting on it.
14482                //
14483                // Only for the frame on screen, because the slot holds one generation.
14484                // Counts for two frames run at once — a join, a query, a filter or a
14485                // sort takes a fresh `len_generation` without stopping the count already
14486                // running — so a failure arriving is not necessarily this frame's.
14487                // Written unconditionally, an orphan's failure overwrote a live frame's,
14488                // `count_unknown` went false, and the bar fell through from "?" to the
14489                // number the buffer happened to reach: a confident partial on a dataset
14490                // whose count failed. The orphan's own failure is worth nothing to
14491                // anybody — nothing will ever render against a generation that is gone.
14492                if self
14493                    .data_table_state
14494                    .as_ref()
14495                    .is_some_and(|state| state.len_generation() == *len_generation)
14496                {
14497                    self.len_count_failed = Some(*len_generation);
14498                }
14499                // Only for the count End was actually waiting on. Taken unconditionally,
14500                // a count that failed for one frame answered for an End pressed on
14501                // another — printing "Could not count the rows to find the end" about a
14502                // key the user pressed somewhere else entirely, and long since.
14503                if self.end_after_count == Some(*len_generation) {
14504                    self.end_after_count = None;
14505                    if self
14506                        .data_table_state
14507                        .as_ref()
14508                        .is_some_and(|state| state.len_generation() == *len_generation)
14509                    {
14510                        self.status_message =
14511                            Some("Could not count the rows to find the end".to_string());
14512                    } else {
14513                        // The frame it was counting is gone, so its failure says nothing
14514                        // about the one on screen, and the End it belonged to cannot be
14515                        // answered by it. Retired quietly, as above.
14516                        self.take_down_the_counting_status();
14517                    }
14518                }
14519                None
14520            }
14521            AppEvent::LinesIndexed { generation, rows } => {
14522                self.lines_indexed(*generation, *rows);
14523                None
14524            }
14525            AppEvent::BackgroundFootersJoined { .. } => {
14526                // Taken whoever the event belongs to, and judged by what is *in* the
14527                // slot rather than by the event that woke us. Two passes can be running
14528                // at once, and the newer one may have overwritten the slot before the
14529                // older one's event is handled: judging by the event would throw the
14530                // newer answer away and leave the dataset on screen waiting for one
14531                // that has already been and gone. An entry is also worth draining
14532                // either way — it is a dataset's worth of schema and every file name.
14533                let taken = self
14534                    .pending_footers_result
14535                    .lock()
14536                    .unwrap_or_else(|e| e.into_inner())
14537                    .take();
14538                // Whether this is still the dataset on screen. Not whether an open is in
14539                // flight: going home leaves the dataset up and puts any open down, and
14540                // coming straight back to it must not find it stranded on two footers
14541                // for the rest of the session.
14542                if let Some((slot_generation, found)) = taken
14543                    && slot_generation == self.dataset_generation
14544                {
14545                    let Some(found) = found else {
14546                        // The pass could not read them. The dataset stays as it opened
14547                        // and stops waiting, so it can go and count itself the ordinary
14548                        // way rather than never at all — which is what the collect
14549                        // below sets going, since it is the counting the dataset was
14550                        // declining while it waited.
14551                        if let Some(state) = self.data_table_state.as_mut() {
14552                            state.give_up_on_pending_footers();
14553                        }
14554                        // The pass is not bringing a count after all, so the jump goes
14555                        // back to waiting on the ordinary one the collect starts. Owed
14556                        // rather than run: the collect bumps `task_generation`, and an
14557                        // export or an analysis may be waiting on the one it would bump
14558                        // past. `reread_when_the_work_allows` runs it the moment that
14559                        // work is done.
14560                        self.reread_owed = Some(slot_generation);
14561                        self.reread_when_the_work_allows();
14562                        return None;
14563                    };
14564                    self.footers_held = Some((slot_generation, found));
14565                    if self.join_held_footers() {
14566                        self.reread_after_the_footers_joined();
14567                    }
14568                }
14569                None
14570            }
14571            AppEvent::BackgroundQualitySampleKept { kept } => {
14572                self.retain_quality_sample(kept);
14573                None
14574            }
14575            AppEvent::BackgroundQualityCopyKept {
14576                dataset_generation,
14577                copy,
14578            } => {
14579                self.retain_quality_copy(*dataset_generation, copy.clone());
14580                None
14581            }
14582            AppEvent::OpenNamed(paths, options) => {
14583                if let Some(event) = Self::route_named_without_looking(paths, options) {
14584                    return Some(event);
14585                }
14586                let (paths, options) = (paths.clone(), options.clone());
14587                let formats = self.formats.clone();
14588                // The open's first phase. Unleased, as the look is: an answer for an open
14589                // the user has left (Ctrl+O) is thrown away by the loader, not waited for.
14590                self.make_way_for_an_open();
14591                let load = self.loading.look_at_paths();
14592                self.spawn_job(Job::OpenNamed(load), Some("Scanning input..."), move |_| {
14593                    if let Some(missing) = Self::missing_named_path(&paths, &formats) {
14594                        return Ok(Answer::NamedPathMissing(missing));
14595                    }
14596                    let (paths, options, directory) =
14597                        match Self::route_named_paths_with(paths, options, &formats) {
14598                            AppEvent::LookThenOpenDirectory(dir, options) => {
14599                                (Vec::new(), options, Some(dir))
14600                            }
14601                            AppEvent::Open(paths, options) => (paths, options, None),
14602                            _ => unreachable!("a named path is opened or looked at"),
14603                        };
14604                    Ok(Answer::NamedPaths {
14605                        paths,
14606                        options: Box::new(options),
14607                        directory,
14608                    })
14609                });
14610                None
14611            }
14612            AppEvent::LookThenOpenDirectory(dir, options) => {
14613                // The name on the wait, so the first frame says which directory is being
14614                // looked at rather than sitting blank. `spawn_job` puts the throbber up
14615                // and the keys that survive it — Ctrl+C, Ctrl+O — keep working, which
14616                // is the whole of what doing this on the event thread cost.
14617                let looking = dir.clone();
14618                let options = options.clone();
14619                self.make_way_for_an_open();
14620                let load = self.loading.look_at_directory(looking.clone());
14621                // A newer look replaces an older one.
14622                self.jobs
14623                    .supersede(|job| matches!(job, Job::LookAtDirectory { .. }));
14624                // The same words the loading screen shows, so the control bar and the
14625                // screen above it do not name the wait two different ways.
14626                // Unleased. A lease exists to make a bump wait for an answer that
14627                // would otherwise be stranded — and this answer is *meant* to be
14628                // thrown away when the user moves on, which is the whole of the guard
14629                // below. Leased, it made everything else wait instead: Ctrl+O out of a
14630                // seventeen-second look and open a small CSV, and its buffer collect
14631                // was owed until the abandoned look finally returned.
14632                // Advertising Ctrl+O as the way out of the wait and then holding the
14633                // next dataset behind it is the wait again, wearing a different hat.
14634                #[cfg(feature = "cloud")]
14635                let (cloud, runtime) = (self.app_config.cloud.clone(), self.runtime.clone());
14636                let job = Job::LookAtDirectory {
14637                    load,
14638                    path: looking.clone(),
14639                };
14640                self.spawn_job(job, Some(Self::LOOKING_AT_A_DIRECTORY), move |_| {
14641                    #[cfg(feature = "cloud")]
14642                    if home::is_object_store_url(&looking) {
14643                        let url = looking.to_string_lossy().into_owned();
14644                        let peeked = wait_on_runtime(&runtime, async move {
14645                            crate::cloud_browse::peek_kind(&url, &cloud).await
14646                        })
14647                        .and_then(Result::ok);
14648                        let (kind, holds) = match peeked {
14649                            Some((kind, holds)) => (kind, Some(Box::new(holds))),
14650                            None => (discover::EntryKind::Unknown, None),
14651                        };
14652                        return Ok(Answer::LookedAt {
14653                            kind,
14654                            holds,
14655                            options: Box::new(options),
14656                        });
14657                    }
14658                    // A panic here used to unwind through `run()` and report a crash,
14659                    // because the look was made on the way to the first frame. On a
14660                    // worker it is swallowed with the dropped handle instead, and nothing
14661                    // would ever be sent: the spinner would stay up and the directory
14662                    // unopened for as long as the user waited. Caught, so the answer is
14663                    // "a directory" and the home screen opens on it. Read the way this
14664                    // open will read them, so the rule judges the directory the user is
14665                    // about to see rather than one nobody will open.
14666                    let as_read = Self::read_as(&options);
14667                    let looked = logging::catch_panic(|| {
14668                        let mut entry = discover::Entry::directory(&looking);
14669                        entry.kind = discover::EntryKind::Unknown;
14670                        home::look_into_as(&entry, &as_read)
14671                    });
14672                    let kind = match looked {
14673                        Ok(entry) => entry.kind,
14674                        Err(_) => discover::EntryKind::Directory,
14675                    };
14676                    Ok(Answer::LookedAt {
14677                        kind,
14678                        holds: None,
14679                        options: Box::new(options),
14680                    })
14681                });
14682                None
14683            }
14684            AppEvent::ClassifyThenOpen { path, jump } => {
14685                // A second Enter replaces the first rather than being refused. Every key
14686                // acts on the home screen even while `busy`, so a second one is
14687                // reachable, and the newer look is the one the user is waiting for — and
14688                // refusing meant a look at a share that never answers killed the feature
14689                // for the rest of the session, silently.
14690                let looking = path.clone();
14691                self.jobs.supersede(|job| matches!(job, Job::Classify(_)));
14692                let look = Job::Classify(jobs::Classify {
14693                    path: looking.clone(),
14694                    browsing: self.home.browsing.clone(),
14695                    jump: *jump,
14696                });
14697                let name = looking
14698                    .file_name()
14699                    .map(|n| n.to_string_lossy().into_owned())
14700                    .unwrap_or_else(|| looking.display().to_string());
14701                // The home screen's own line, because the control bar's is the table's.
14702                self.home.status = Some(format!("Looking at {name}..."));
14703                self.spawn_job(look, Some(Self::LOOKING), move |_| {
14704                    // Every one of these can sit forever on a share that has gone away,
14705                    // which is the whole reason they are here and not where keys are read.
14706                    let found = if !looking.exists() {
14707                        None
14708                    } else if looking.is_dir() {
14709                        Some(crate::discover::classify_directory(&looking))
14710                    } else {
14711                        Some(crate::discover::EntryKind::File)
14712                    };
14713                    Ok(Answer::Kind(found))
14714                });
14715                None
14716            }
14717            AppEvent::JobEnded(ticket) => self.job_ended(*ticket),
14718            AppEvent::JobProgress { ticket, progress } => {
14719                self.job_progress(*ticket, progress);
14720                None
14721            }
14722            AppEvent::QQuery(query) => {
14723                self.run_query(QueryMode::Q, query, "Applying query...");
14724                None
14725            }
14726            AppEvent::SqlQuery(sql) => {
14727                self.run_query(QueryMode::Sql, sql, "Applying SQL query...");
14728                None
14729            }
14730            AppEvent::Filter(statements) => {
14731                if let Some(state) = &mut self.data_table_state {
14732                    state.deferred(|s| s.filter(statements.clone()));
14733                }
14734                self.spawn_async_collect("Filtering...");
14735                None
14736            }
14737            AppEvent::Sort(columns, descending) => {
14738                if let Some(state) = &mut self.data_table_state {
14739                    state.deferred(|s| s.sort_by(columns.clone(), descending.clone()));
14740                }
14741                self.spawn_async_collect("Sorting...");
14742                None
14743            }
14744            AppEvent::Reset => {
14745                // The sample is a step of the view: a reset takes it away too.
14746                if self
14747                    .data_table_state
14748                    .as_ref()
14749                    .is_some_and(|state| state.sampled().is_some())
14750                {
14751                    self.put_down_sample_draw();
14752                    if let Some(state) = self.data_table_state.take() {
14753                        self.data_table_state = Some(state.into_unsampled());
14754                    }
14755                    self.sample_changed();
14756                }
14757                if let Some(state) = &mut self.data_table_state {
14758                    state.deferred(|s| s.reset());
14759                }
14760                self.spawn_async_collect(Self::LOADING_BUFFER);
14761                // Clear active view when resetting
14762                self.active_view_id = None;
14763                None
14764            }
14765            AppEvent::ColumnOrder(order, locked_count) => {
14766                if let Some(state) = &mut self.data_table_state {
14767                    state.deferred(|s| {
14768                        s.set_column_order(order.clone());
14769                        s.set_locked_columns(*locked_count);
14770                    });
14771                    self.spawn_async_collect(Self::LOADING_BUFFER);
14772                }
14773                None
14774            }
14775            AppEvent::Pivot(spec) => {
14776                // The modal stays up until the result is in, so a pivot that fails
14777                // leaves the spec there to fix.
14778                let job = self.data_table_state.as_ref()?.plan_pivot(spec);
14779                let spec = spec.clone();
14780                self.spawn_job(Job::Pivot, Some(Self::COMPUTING_PIVOT), move |_| {
14781                    let pivoted = job
14782                        .run()
14783                        .map_err(|e| crate::error_display::user_message_from_report(&e, None))?;
14784                    Ok(Answer::Pivoted { spec, pivoted })
14785                });
14786                None
14787            }
14788            AppEvent::Melt(spec) => {
14789                self.busy = true;
14790                if let Some(state) = &mut self.data_table_state {
14791                    let result = state.deferred(|s| s.melt(spec));
14792                    match result {
14793                        Ok(()) => {
14794                            self.pivot_melt_modal.close();
14795                            self.input_mode = InputMode::Normal;
14796                            self.spawn_async_collect("Computing melt...");
14797                            None
14798                        }
14799                        Err(e) => {
14800                            self.busy = false;
14801                            self.error_modal
14802                                .show(crate::error_display::user_message_from_report(&e, None));
14803                            None
14804                        }
14805                    }
14806                } else {
14807                    self.busy = false;
14808                    None
14809                }
14810            }
14811            AppEvent::QualityReportExport(path, format, overwrite) => {
14812                // The report on screen and the plan it was measured with, cloned into
14813                // the writer: the file is built from memory and nothing is read.
14814                let results = self.analysis_modal.data_quality_results.clone()?;
14815                let plan = self.analysis_modal.quality_result_plan().clone();
14816                let (path, format, overwrite) = (path.clone(), *format, *overwrite);
14817                self.spawn_job(
14818                    Job::QualityReport,
14819                    Some("Writing the report..."),
14820                    move |_| {
14821                        crate::quality_export::write(&path, &results, &plan, format, overwrite)
14822                            .map_err(|error| Self::format_export_error(&error))?;
14823                        Ok(Answer::QualityReportWritten(path))
14824                    },
14825                );
14826                None
14827            }
14828            AppEvent::ChartExport(request) => {
14829                self.busy = true;
14830                self.export_progress = Some(ExportProgress {
14831                    file_path: request.path.clone(),
14832                    current_phase: "Exporting chart".to_string(),
14833                    written: None,
14834                });
14835                Some(AppEvent::DoChartExport(request.clone()))
14836            }
14837            AppEvent::DoChartExport(request) => {
14838                // `ChartExport` arms `busy` and defers here so the phase can be drawn
14839                // first. A Ctrl-O in that window has already left the chart view, and
14840                // there is nothing to export any more: release the app rather than park
14841                // an export that no view would ever prepare.
14842                if self.input_mode != InputMode::Chart || !self.chart_modal.active {
14843                    self.export_progress = None;
14844                    self.status_message = None;
14845                    self.busy = false;
14846                    return None;
14847                }
14848                self.start_chart_export(request.clone());
14849                None
14850            }
14851            AppEvent::BackgroundChartReady => {
14852                // The result belongs to the one preparation in flight. It is installed
14853                // only while that record is current (a reset marks it stale when its
14854                // view or dataset goes) and only into the dataset it was computed from.
14855                // Taking the record is what lets the next request start; the slot is
14856                // emptied either way so a discarded series is not kept around.
14857                let inflight = self.chart_inflight.take()?;
14858                let outcome = self
14859                    .pending_chart_result
14860                    .lock()
14861                    .unwrap_or_else(|e| e.into_inner())
14862                    .take()
14863                    .unwrap_or_else(|| Err("Chart preparation produced no result".to_string()));
14864                if inflight.stale {
14865                    return None;
14866                }
14867                // A count stopped part way is no answer, and must not be remembered as
14868                // a failure; the selection is prepared again when it comes back.
14869                if outcome.is_err() && inflight.cancel.load(std::sync::atomic::Ordering::Relaxed) {
14870                    return None;
14871                }
14872                let dataset = self.data_table_state.as_ref().map(|s| s.len_generation());
14873                if dataset != inflight.dataset {
14874                    return None;
14875                }
14876                let outcome = outcome.map(|(prepared, colors)| {
14877                    if let Some(colors) = colors {
14878                        self.chart_cache.hold_colors(colors.clone());
14879                        self.chart_modal.color_counts = Some(colors);
14880                    }
14881                    prepared
14882                });
14883                self.chart_cache.insert(inflight.request, outcome);
14884                // An export parked on chart data resumes against the *current*
14885                // selection, whatever just landed: it is written if that selection is
14886                // now prepared, fails with the reason if that is the one that failed,
14887                // and otherwise waits for the next result (which `ensure_chart_data`
14888                // starts once this handler returns).
14889                if let Some(request) = self.chart_export_waiting.take() {
14890                    self.start_chart_export(request);
14891                }
14892                None
14893            }
14894            AppEvent::Export(request) => {
14895                if self.data_table_state.is_some() {
14896                    self.busy = true;
14897                    self.export_progress = Some(ExportProgress {
14898                        file_path: request.path.clone(),
14899                        current_phase: "Preparing export".to_string(),
14900                        written: None,
14901                    });
14902                    // Drawn before the export starts.
14903                    Some(AppEvent::DoExport(request.clone()))
14904                } else {
14905                    None
14906                }
14907            }
14908            AppEvent::Followed(news) => {
14909                self.followed(news);
14910                None
14911            }
14912            AppEvent::FollowedDetail {
14913                dataset_generation,
14914                detail,
14915            } => {
14916                if *dataset_generation == self.dataset_generation
14917                    && let Some(state) = self.data_table_state.as_mut()
14918                {
14919                    state.set_format_detail((**detail).clone());
14920                }
14921                None
14922            }
14923            AppEvent::DoExport(request) => {
14924                let Some(state) = &self.data_table_state else {
14925                    self.export_progress = None;
14926                    self.busy = false;
14927                    return None;
14928                };
14929                // Cloned, not taken: a failed write reopens the dialog on the same counts.
14930                let frame = match self.export_counts.clone() {
14931                    Some(counts) => crate::widgets::datatable::ExportFrame::of(
14932                        polars::prelude::IntoLazy::lazy(counts),
14933                    ),
14934                    None => state.export_frame(request.options.source_file),
14935                };
14936                let streaming = state.polars_streaming();
14937                // One job from plan to commit: it holds the generation throughout,
14938                // and the rows it collects, if it collects, die with it.
14939                let phase = match request.route(streaming) {
14940                    crate::export::Route::Streamed => Self::export_write_phase(request),
14941                    crate::export::Route::Collected => "Collecting data",
14942                };
14943                self.export_progress = Some(ExportProgress {
14944                    file_path: request.path.clone(),
14945                    current_phase: phase.to_string(),
14946                    written: None,
14947                });
14948                let writing = Self::export_write_phase(request);
14949                let request = request.clone();
14950                self.spawn_job(Job::Export, Some("Exporting..."), move |worker| {
14951                    let report = worker.reporter();
14952                    let written = move |bytes| {
14953                        report(Progress::ExportWriting {
14954                            phase: writing,
14955                            bytes,
14956                        })
14957                    };
14958                    frame
14959                        .into_lazy()
14960                        .map_err(color_eyre::eyre::Report::from)
14961                        .and_then(|lf| crate::export::run(lf, &request, streaming, written))
14962                        .map_err(|e| Self::format_export_error(&e))?;
14963                    // Success is reported only once the file is committed.
14964                    Ok(Answer::Exported(request.path))
14965                });
14966                None
14967            }
14968            AppEvent::OpenLink(url) => {
14969                // Started, not waited on; a browser that will not start is a line,
14970                // not an error to acknowledge.
14971                if link_open::open(url).is_err() {
14972                    self.flash_note("Couldn't open the link; y copies it".to_string());
14973                }
14974                None
14975            }
14976            AppEvent::CopyTable { format, header } => {
14977                let accepts = match self.copy_destination() {
14978                    Ok(destination) => destination.accepts(),
14979                    Err(e) => {
14980                        self.busy = false;
14981                        self.error_modal.show(e);
14982                        return None;
14983                    }
14984                };
14985                if let Some(state) = &self.data_table_state {
14986                    let lf = state.visible_lf();
14987                    let streaming = state.polars_streaming();
14988                    let (format, header) = (*format, *header);
14989                    self.spawn_job(Job::Copy, Some("Collecting data for copy..."), move |_| {
14990                        // A capped destination's copy is read in batches and given
14991                        // up at the cap; any other is collected and built whole.
14992                        let (payload, rows) = match accepts.base64_limit {
14993                            Some(limit) => crate::clipboard::bounded_table_text(
14994                                lf, format, header, limit,
14995                            )
14996                            .map(|(text, rows)| (crate::clipboard::Payload::text(text), rows)),
14997                            None => crate::statistics::collect_lazy(lf, streaming)
14998                                .map_err(|e| crate::error_display::user_message_from_polars(&e))
14999                                .and_then(|df| {
15000                                    crate::clipboard::tabular_payload(
15001                                        &df,
15002                                        format,
15003                                        header,
15004                                        accepts.html,
15005                                    )
15006                                    .map(|payload| (payload, df.height()))
15007                                }),
15008                        }
15009                        .map_err(|message| format!("Copy failed: {message}"))?;
15010                        // Handed on whole, never copied.
15011                        Ok(Answer::Copied {
15012                            payload,
15013                            message: format!(
15014                                "Copied {} rows as {}",
15015                                copy_modal::thousands(rows),
15016                                format.as_str()
15017                            ),
15018                        })
15019                    });
15020                } else {
15021                    self.busy = false;
15022                }
15023                None
15024            }
15025            AppEvent::TerminalBackground(mode) => {
15026                self.terminal_answered(*mode);
15027                None
15028            }
15029            AppEvent::TerminalFocused => {
15030                self.background_query |= self.app_config.theme.follow;
15031                None
15032            }
15033            _ => None,
15034        }
15035    }
15036
15037    /// What `column` holds, for an export's axis ticks.
15038    fn axis_numbers(&self, column: &str) -> chart_data::AxisNumbers {
15039        let schema = self.data_table_state.as_ref().map(|s| s.schema().as_ref());
15040        chart_data::AxisNumbers::column(&self.number_format, schema, column)
15041    }
15042
15043    /// What `columns` hold on one axis.
15044    fn axes_numbers(&self, columns: &[String]) -> chart_data::AxisNumbers {
15045        let schema = self.data_table_state.as_ref().map(|s| s.schema().as_ref());
15046        chart_data::AxisNumbers::columns(&self.number_format, schema, columns)
15047    }
15048
15049    /// The figure to export from the prepared chart for the current spec. `Ok(None)`
15050    /// means that chart is still being prepared and the caller should wait for it.
15051    fn build_chart_figure(&self) -> Result<Option<chart_export::Figure>> {
15052        use chart_export::{Axis, Figure, Plot, Series};
15053        if self.data_table_state.is_none() {
15054            return Err(color_eyre::eyre::eyre!("No data loaded"));
15055        }
15056        let modal = &self.chart_modal;
15057        let Some(request) = ChartRequest::from_modal(modal).filter(|r| !r.x_only) else {
15058            return Err(color_eyre::eyre::eyre!(
15059                "Pick the columns the chart needs first"
15060            ));
15061        };
15062        let prepared = match self.chart_cache.get(&request) {
15063            Some(Ok(prepared)) => prepared,
15064            // A selection known not to chart is never retried, so waiting for its data
15065            // would wait forever: fail the export now with the reason.
15066            Some(Err(message)) => return Err(color_eyre::eyre::eyre!("{}", message)),
15067            None => return Ok(None),
15068        };
15069        let no_points = || color_eyre::eyre::eyre!("No valid data points to export");
15070        let spec = &request.spec;
15071        let x_name = spec.encoding.x.field.clone().unwrap_or_default();
15072        let ys = &spec.encoding.y.field;
15073        let title = |column: &str| modal.axis_title(column);
15074        let numbers_of = |column: &str| self.axis_numbers(column);
15075        let y_axis = || {
15076            use chart_modal::Aggregate;
15077            let aggregate = spec.encoding.y.aggregate;
15078            let numbers = match aggregate {
15079                Aggregate::Count | Aggregate::Distinct => {
15080                    chart_data::AxisNumbers::count(&self.number_format)
15081                }
15082                a if a.is_fractional() => self.axes_numbers(ys).fractional(),
15083                _ => self.axes_numbers(ys),
15084            };
15085            let names = if aggregate == Aggregate::Count {
15086                "count".to_string()
15087            } else {
15088                ys.iter().map(|y| title(y)).collect::<Vec<_>>().join(", ")
15089            };
15090            let title = match aggregate {
15091                Aggregate::None | Aggregate::Count => names,
15092                _ => format!("{} {names}", spec.encoding.y.aggregate_name()),
15093            };
15094            Axis {
15095                title,
15096                numbers,
15097                log: modal.log_scale,
15098                ..Default::default()
15099            }
15100        };
15101        let plot = match prepared {
15102            ChartPrepared::XY(cache) => {
15103                let points = if modal.log_scale {
15104                    cache
15105                        .series_log
15106                        .clone()
15107                        .unwrap_or_else(|| log_series(&cache.series))
15108                } else {
15109                    cache.series.clone()
15110                };
15111                let last = cache.names.len().saturating_sub(1);
15112                let series: Vec<Series> = points
15113                    .into_iter()
15114                    .zip(&cache.names)
15115                    .zip(&cache.breaks)
15116                    .enumerate()
15117                    .filter(|(_, ((points, _), _))| !points.is_empty())
15118                    .map(|(i, ((points, name), breaks))| Series {
15119                        name: name.clone(),
15120                        points,
15121                        breaks: breaks.clone(),
15122                        other: cache.other && i == last,
15123                    })
15124                    .collect();
15125                if series.is_empty() {
15126                    return Err(no_points());
15127                }
15128                Plot::Lines {
15129                    series,
15130                    scatter: spec.mark == chart_modal::Mark::Scatter,
15131                    x: Axis {
15132                        title: title(&cache.x_column),
15133                        numbers: numbers_of(&cache.x_column),
15134                        kind: cache.x_axis_kind,
15135                        log: false,
15136                    },
15137                    y: y_axis(),
15138                    y_from_zero: modal.y_starts_at_zero,
15139                }
15140            }
15141            ChartPrepared::Histogram(data) => {
15142                if data.bins.is_empty() {
15143                    return Err(no_points());
15144                }
15145                Plot::Histogram {
15146                    data: data.clone(),
15147                    x: Axis {
15148                        title: title(&data.column),
15149                        numbers: numbers_of(&data.column),
15150                        ..Default::default()
15151                    },
15152                    y: Axis {
15153                        title: if data.share { "share" } else { "count" }.to_string(),
15154                        numbers: if data.share {
15155                            chart_data::AxisNumbers::measure(&self.number_format, "Share")
15156                        } else {
15157                            chart_data::AxisNumbers::count(&self.number_format)
15158                        },
15159                        ..Default::default()
15160                    },
15161                }
15162            }
15163            ChartPrepared::BoxPlot(data) => {
15164                if data.stats.is_empty() {
15165                    return Err(no_points());
15166                }
15167                Plot::Box {
15168                    data: data.clone(),
15169                    x_title: x_name.clone(),
15170                    y: Axis {
15171                        title: ys.first().map(|y| title(y)).unwrap_or_default(),
15172                        numbers: self.axes_numbers(ys),
15173                        ..Default::default()
15174                    },
15175                }
15176            }
15177            ChartPrepared::Kde(data) => {
15178                if data.series.is_empty() {
15179                    return Err(no_points());
15180                }
15181                Plot::Kde {
15182                    data: data.clone(),
15183                    x: Axis {
15184                        title: title(&x_name),
15185                        numbers: numbers_of(&x_name).fractional(),
15186                        ..Default::default()
15187                    },
15188                    y: Axis {
15189                        title: "density".to_string(),
15190                        numbers: chart_data::AxisNumbers::measure(&self.number_format, "Density"),
15191                        ..Default::default()
15192                    },
15193                }
15194            }
15195            ChartPrepared::Heatmap(data) => {
15196                if data.counts.is_empty() || data.max_count <= 0.0 {
15197                    return Err(no_points());
15198                }
15199                Plot::Heatmap {
15200                    data: data.clone(),
15201                    x: Axis {
15202                        title: title(&data.x_column),
15203                        numbers: numbers_of(&data.x_column),
15204                        ..Default::default()
15205                    },
15206                    y: Axis {
15207                        title: title(&data.y_column),
15208                        numbers: numbers_of(&data.y_column),
15209                        ..Default::default()
15210                    },
15211                }
15212            }
15213            ChartPrepared::Bar(data) => {
15214                if data.bars.is_empty() {
15215                    return Err(no_points());
15216                }
15217                Plot::Bars {
15218                    value: Axis {
15219                        title: data.value_column.clone(),
15220                        numbers: chart_data::AxisNumbers {
15221                            format: data.value_format(&self.number_format),
15222                            whole: data.value_dtype.is_integer(),
15223                        },
15224                        ..Default::default()
15225                    },
15226                    data: data.clone(),
15227                }
15228            }
15229            // Left out above: a single X column has nothing to export.
15230            ChartPrepared::XRange(_) => return Err(no_points()),
15231        };
15232        Ok(Some(Figure {
15233            plot,
15234            // The file always has the middle dot; the terminal may be ASCII.
15235            chart_notes: self.chart_notes_of(prepared, "·"),
15236            grid: modal.grid,
15237        }))
15238    }
15239
15240    /// Write the chart from the prepared data off-thread, or park the export until that
15241    /// data is ready. `busy` was set by `ChartExport` and stays set until the export ends.
15242    fn start_chart_export(&mut self, mut request: ChartExportRequest) {
15243        // How the chart was made, from the view and chart as they are now; none
15244        // when the dialog says Omit.
15245        request.options.recipe = if request.recipe {
15246            self.chart_recipe()
15247        } else {
15248            None
15249        };
15250        match self.build_chart_figure() {
15251            Ok(Some(figure)) => {
15252                self.chart_export_waiting = None;
15253                let write = Job::ChartExport {
15254                    path: request.path.clone(),
15255                    format: request.format,
15256                };
15257                self.spawn_job(write, Some("Exporting chart..."), move |_| {
15258                    let ChartExportRequest {
15259                        path,
15260                        format,
15261                        options,
15262                        overwrite,
15263                        ..
15264                    } = request;
15265                    ChartExportJob { figure, options }
15266                        .write(&path, format, overwrite)
15267                        .map_err(|e| Self::format_export_error(&e))?;
15268                    Ok(Answer::ChartExported)
15269                });
15270            }
15271            // Still being prepared; `BackgroundChartReady` comes back here.
15272            Ok(None) => self.chart_export_waiting = Some(request),
15273            Err(e) => {
15274                let message = Self::format_export_error(&e);
15275                self.finish_chart_export(&request.path, request.format, Err(message));
15276            }
15277        }
15278    }
15279
15280    fn finish_chart_export(
15281        &mut self,
15282        path: &Path,
15283        format: ChartExportFormat,
15284        result: Result<(), String>,
15285    ) {
15286        self.chart_export_waiting = None;
15287        self.export_progress = None;
15288        self.status_message = None;
15289        self.busy = false;
15290        match result {
15291            Ok(()) => {
15292                self.flash_path("Chart exported to ", path);
15293                self.chart_export_modal.close();
15294            }
15295            // The form comes back as it was, the reason on its status line.
15296            Err(message) => {
15297                self.chart_export_modal.reopen_with_path(path, format);
15298                self.chart_export_modal.error = Some(message);
15299            }
15300        }
15301    }
15302
15303    /// Why one of the sidebar's filters cannot apply: its value does not read as its
15304    /// column's type. The first such, said for the user.
15305    fn filter_problem(&self) -> Option<String> {
15306        let schema = self.data_table_state.as_ref()?.schema();
15307        self.sort_filter_modal
15308            .filter
15309            .statements
15310            .iter()
15311            .find_map(|f| crate::python_script::SidebarFilter::problem(f, schema.get(&f.column)))
15312    }
15313
15314    /// Whether the dataset on screen is delimited text, whose first row `H` on the
15315    /// Info panel's Schema tab reads the other way.
15316    pub fn header_toggle_offered(&self) -> bool {
15317        self.opened
15318            .as_ref()
15319            .and_then(|(_, options)| options.format)
15320            .and_then(FileFormat::separator)
15321            .is_some()
15322    }
15323
15324    /// Read the dataset again with its first row the other way: as column names, or
15325    /// as data under `column_1`, `column_2`, …. Only delimited text has a header to
15326    /// turn off; anything else carries its own names, and this does nothing there.
15327    pub(crate) fn toggle_header(&mut self) -> Option<AppEvent> {
15328        if !self.header_toggle_offered() {
15329            return None;
15330        }
15331        let (paths, options) = self.opened.clone()?;
15332        let options = OpenOptions {
15333            has_header: Some(!options.has_header.unwrap_or(true)),
15334            ..options
15335        };
15336        self.set_loading_phase("Scanning input", 10);
15337        self.name_what_is_loading(paths[0].clone());
15338        Some(AppEvent::Open(paths, options))
15339    }
15340
15341    /// `H` / `L`: the column cursor's column one place left or right in the column
15342    /// order the sidebar's `+` / `-` set, the cursor with it. A frozen column moves
15343    /// among the frozen ones and a scrolling one among the scrolling ones; at an end,
15344    /// nothing moves.
15345    fn move_cursor_column(&mut self, right: bool) -> Option<AppEvent> {
15346        let state = self.data_table_state.as_ref()?;
15347        let at = state.current_column_index()?;
15348        let mut order = state.headers();
15349        let locked = state.locked_columns_count().min(order.len());
15350        let to = if right { at + 1 } else { at.checked_sub(1)? };
15351        if to >= order.len() || (at < locked) != (to < locked) {
15352            return None;
15353        }
15354        // Held by name, so it lands on the column where the move puts it.
15355        let moving = order[at].clone();
15356        self.data_table_state.as_mut()?.set_current_column(&moving);
15357        order.swap(at, to);
15358        // The sidebar places hidden columns by the order it last applied; the two
15359        // trade places there too, so that order still agrees with the table.
15360        let applied = &mut self.sort_filter_modal.sort.applied_order;
15361        if let (Some(i), Some(j)) = (
15362            applied.iter().position(|c| *c == order[at]),
15363            applied.iter().position(|c| *c == order[to]),
15364        ) {
15365            applied.swap(i, j);
15366        }
15367        Some(AppEvent::ColumnOrder(order, locked))
15368    }
15369
15370    /// `+` / `-`: a filter on the cursor's cell, added to the sidebar's Filters list
15371    /// and applied, so it shows there, joins the others with "and", and `R` clears
15372    /// it. `+` keeps the rows with the cell's value and `-` drops them; a null cell
15373    /// is "is null" or "not null". The value is the cell's exactly as stored.
15374    /// `[` / `]` at the table: sort by the cursor's column, ascending or descending,
15375    /// in place of the sort in effect. The same key again on a view sorted that way
15376    /// by that column alone takes the sort away.
15377    fn sort_by_cursor_column(&mut self, descending: bool) -> Option<AppEvent> {
15378        let state = self.data_table_state.as_ref()?;
15379        let column = state.current_column()?.to_string();
15380        let already = state.view_sort_columns() == std::slice::from_ref(&column)
15381            && state.view_sort_descending() == [descending];
15382        if already {
15383            // Back to the natural order: `sort` with no columns resets the direction
15384            // `]` left behind, which would otherwise read as a reversal.
15385            if let Some(state) = self.data_table_state.as_mut() {
15386                state.deferred(|s| s.sort(Vec::new(), true));
15387            }
15388            self.spawn_async_collect("Sorting...");
15389            return None;
15390        }
15391        Some(AppEvent::Sort(vec![column], vec![descending]))
15392    }
15393
15394    fn quick_filter(&mut self, keep: bool) -> Option<AppEvent> {
15395        let state = self.data_table_state.as_ref()?;
15396        let column = state.current_column()?.to_string();
15397        let row = state.copy_row_df()?;
15398        let series = row.column(&column).ok()?.as_materialized_series().clone();
15399        let value = series.get(0).ok()?;
15400        // The schema's type, not the buffer's: binary is buffered as a stub.
15401        let dtype = state.schema().get(&column)?.clone();
15402        let (operator, text) = if value.is_null() {
15403            let operator = if keep {
15404                FilterOperator::IsNull
15405            } else {
15406                FilterOperator::IsNotNull
15407            };
15408            (operator, String::new())
15409        } else {
15410            let operator = if keep {
15411                FilterOperator::Eq
15412            } else {
15413                FilterOperator::NotEq
15414            };
15415            // Text that reads back to this very value: a float exactly as stored,
15416            // a date and time to its last digit, in its zone.
15417            let text = crate::typed_value::text_of(&value, &dtype);
15418            let Some(text) = text else {
15419                let kind = match dtype {
15420                    DataType::List(_) => "lists",
15421                    DataType::Array(..) => "arrays",
15422                    DataType::Struct(_) => "structs",
15423                    DataType::Binary | DataType::BinaryOffset => "binary",
15424                    _ => "this type",
15425                };
15426                self.flash_note(format!("+ and - filter on plain values, not {kind}"));
15427                return None;
15428            };
15429            (operator, text)
15430        };
15431        let statement = FilterStatement {
15432            columns: Vec::new(),
15433            column,
15434            operator,
15435            value: text,
15436            logical_op: LogicalOperator::And,
15437        };
15438        let mut statements = state.view_filters().to_vec();
15439        if statements.contains(&statement) {
15440            return None;
15441        }
15442        statements.push(statement);
15443        Some(AppEvent::Filter(statements))
15444    }
15445
15446    /// Bring the Sort & Filter sidebar in line with the state actually applied to the
15447    /// frame on screen: the real column order and hidden set, the applied sort, the
15448    /// active filters. Called on open, so an edit staged in the modal and then
15449    /// canceled dies with it rather than arriving pre-staged next time — and after a
15450    /// drill-down swap, where a sidebar still showing the grouped view's filters
15451    /// would re-send one against a List column.
15452    fn sync_sort_filter_modal(&mut self) {
15453        let Some(state) = self.data_table_state.as_ref() else {
15454            return;
15455        };
15456        let filters = state.view_filters().to_vec();
15457        let sort_columns = state.view_sort_columns().to_vec();
15458        let sort_descending = state.view_sort_descending().to_vec();
15459        let headers: Vec<String> = state.schema().iter_names().map(|s| s.to_string()).collect();
15460        let schema = state.schema().clone();
15461        let order = state.headers();
15462        let locked = state.locked_columns_count();
15463
15464        let modal = &mut self.sort_filter_modal;
15465        modal.filter.applied = filters.clone();
15466        modal.filter.statements = filters;
15467        modal.filter.operands = order
15468            .iter()
15469            .map(|name| {
15470                schema
15471                    .get(name)
15472                    .map(crate::filter_modal::Operand::of)
15473                    .unwrap_or_default()
15474            })
15475            .collect();
15476        modal.filter.available_columns = order.clone();
15477        // The cursor starts on the add row; the editor never survives a resync.
15478        modal.filter.cursor = modal.filter.statements.len();
15479        modal.filter.editor = None;
15480        // A schema column the applied order leaves out is hidden; it is listed where
15481        // it stood when hidden, so showing it again puts it back there. The order the
15482        // sidebar last applied says where only while the table still shows it; once a
15483        // view, query or reshape has set the order, the schema places them.
15484        let shown: std::collections::HashSet<&str> = order.iter().map(String::as_str).collect();
15485        let applied = &modal.sort.applied_order;
15486        let current = applied
15487            .iter()
15488            .filter(|name| shown.contains(name.as_str()))
15489            .eq(order.iter());
15490        let reference: &[String] = if current { applied } else { &[] };
15491        let full = order_with_hidden(&order, &headers, reference);
15492        let places: HashMap<&str, usize> = full
15493            .iter()
15494            .enumerate()
15495            .map(|(i, name)| (name.as_str(), i))
15496            .collect();
15497        let place = |name: &String| places.get(name.as_str()).copied();
15498        // Everything up to the last frozen column stays frozen, hidden ones included.
15499        // A hidden column that ended the frozen span is known only to the applied order.
15500        let last_locked = applied
15501            .get(..modal.sort.applied_locked)
15502            .filter(|span| {
15503                current && span.iter().filter(|n| shown.contains(n.as_str())).count() == locked
15504            })
15505            .and_then(|span| span.iter().rev().find_map(place))
15506            .or_else(|| {
15507                locked
15508                    .checked_sub(1)
15509                    .and_then(|i| order.get(i))
15510                    .and_then(place)
15511            });
15512        modal.sort.columns = headers
15513            .iter()
15514            .map(|name| {
15515                let display_order = places[name.as_str()];
15516                SortColumn {
15517                    name: name.clone(),
15518                    // 1-based: what toggling a column in the modal assigns and what
15519                    // the sidebar prints.
15520                    sort_order: sort_columns.iter().position(|c| c == name).map(|o| o + 1),
15521                    sort_descending: sort_columns
15522                        .iter()
15523                        .position(|c| c == name)
15524                        .and_then(|i| sort_descending.get(i).copied())
15525                        .unwrap_or(false),
15526                    display_order,
15527                    is_locked: last_locked.is_some_and(|l| display_order <= l),
15528                    is_to_be_locked: false,
15529                    is_visible: shown.contains(name.as_str()),
15530                    width: state.width_choice(name),
15531                    shown_width: state.on_screen_width(name),
15532                }
15533            })
15534            .collect();
15535        modal.sort.has_unapplied_changes = false;
15536    }
15537
15538    /// Apply everything the sidebar stages — column order and locks, the sort with
15539    /// its per-column directions, the filters — and close it. Enter and Ctrl+Enter,
15540    /// from anywhere in the sidebar.
15541    fn apply_sort_filter(&mut self) -> Option<AppEvent> {
15542        // A row still under edit is committed, never silently dropped.
15543        if self.sort_filter_modal.filter.editor.is_some() {
15544            self.sort_filter_modal.filter.commit_editor();
15545        }
15546        // A value its column cannot compare with stays in the sidebar, which says why.
15547        if let Some(why) = self.filter_problem() {
15548            self.sort_filter_modal.sort.status = Some(why);
15549            return None;
15550        }
15551        let (columns, descending) = self.sort_filter_modal.sort.sorted_columns_and_directions();
15552        let column_order = self.sort_filter_modal.sort.get_column_order();
15553        let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
15554        self.sort_filter_modal.sort.applied_order =
15555            self.sort_filter_modal.sort.get_full_column_order();
15556        self.sort_filter_modal.sort.applied_locked = self.sort_filter_modal.sort.get_locked_span();
15557        let statements = self.sort_filter_modal.filter.statements.clone();
15558        // Widths read nothing, so they apply here; a fit measures the rows on screen
15559        // when the table is next drawn. With nothing else changed the view stays
15560        // where it is, on the page the fit was asked for: applying the order, filters
15561        // and sort again would read the rows afresh from the top.
15562        let view_unchanged = self.data_table_state.as_mut().is_some_and(|state| {
15563            state.set_width_choices(self.sort_filter_modal.sort.width_choices());
15564            state.headers() == column_order
15565                && state.locked_columns_count() == locked_count
15566                && state.view_filters() == statements.as_slice()
15567                && state.view_sort_columns() == columns.as_slice()
15568                && state.view_sort_descending() == descending.as_slice()
15569        });
15570        for col in &mut self.sort_filter_modal.sort.columns {
15571            col.is_to_be_locked = false;
15572        }
15573        self.sort_filter_modal.sort.has_unapplied_changes = false;
15574        self.sort_filter_modal.close();
15575        self.input_mode = InputMode::Normal;
15576        if view_unchanged {
15577            return None;
15578        }
15579        let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
15580        let _ = self.send_event(AppEvent::Filter(statements));
15581        Some(AppEvent::Sort(columns, descending))
15582    }
15583
15584    /// Which of the Info panel's optional tabs the current dataset offers.
15585    fn info_tabs_on_offer(&self) -> crate::widgets::info::TabsOffered {
15586        let facts_tab = self.info_facts_tab();
15587        self.data_table_state
15588            .as_ref()
15589            .map(|state| crate::widgets::info::TabsOffered {
15590                documentation: self.info_documentation.is_open(),
15591                ..crate::widgets::info::TabsOffered::of(state, facts_tab)
15592            })
15593            .unwrap_or_default()
15594    }
15595
15596    /// The format of the dataset on screen, as the open read it.
15597    pub(crate) fn opened_format(&self) -> Option<FileFormat> {
15598        self.opened
15599            .as_ref()
15600            .and_then(|(_, options)| options.format)
15601            .or_else(|| self.path.as_deref().and_then(FileFormat::from_path))
15602    }
15603
15604    /// The format's tab of the Info panel that the file facts fill: for one local file,
15605    /// not a hive directory, whose reader has a facts read. See
15606    /// [`crate::widgets::info::InfoContext::facts_tab`].
15607    pub(crate) fn info_facts_tab(&self) -> Option<&'static str> {
15608        self.info_facts()
15609            .and_then(|(format, _)| format.summary_tab())
15610    }
15611
15612    /// The format whose facts read the Info panel's worker makes for the dataset on
15613    /// screen, and that read, once the panel has asked for the file's facts.
15614    pub(crate) fn info_facts(&self) -> Option<(FileFormat, crate::readers::Facts)> {
15615        match self.file_facts()? {
15616            // A directory, which has no footer of its own.
15617            FileFacts::Read {
15618                size: None,
15619                detail: None,
15620                ..
15621            } => None,
15622            _ => self.facts_of_open(),
15623        }
15624    }
15625
15626    /// The facts read for the dataset on screen, if its file has one: one file, stored
15627    /// as its format says (a stream or a compressed copy has no footer).
15628    fn facts_of_open(&self) -> Option<(FileFormat, crate::readers::Facts)> {
15629        let hive = self
15630            .opened
15631            .as_ref()
15632            .is_some_and(|(_, options)| options.hive);
15633        let format = self.opened_format()?;
15634        let facts = crate::readers::of(format).facts?;
15635        let state = self.data_table_state.as_ref()?;
15636        let plain = state
15637            .read_mode()
15638            .is_none_or(|mode| Some(mode) == format.read_mode(crate::Stored::Plain));
15639        // Several files, whose footers the Notes and Schema tabs already sum up.
15640        let one_file = state.dataset_schema().is_none();
15641        (!hive && plain && one_file).then_some((format, facts))
15642    }
15643
15644    /// Start applying `view`. Its steps are planned here, which reads nothing; a
15645    /// step that cannot be planned fails here and changes nothing. The reads — a pivot,
15646    /// then the view's first rows — run in the background, and the view is installed
15647    /// when they are in. One that fails there puts the view before it back (#400).
15648    fn apply_view(&mut self, view: &SavedView) -> Result<()> {
15649        self.apply_view_with(view, None)
15650    }
15651
15652    /// [`Self::apply_view`], for a view applied because its criteria fit as `why`
15653    /// says: once its rows are in, a flash names it and the reason.
15654    fn apply_matched_view(&mut self, view: &SavedView, why: view::MatchReason) -> Result<()> {
15655        self.apply_view_with(view, Some(why))
15656    }
15657
15658    fn apply_view_with(&mut self, view: &SavedView, why: Option<view::MatchReason>) -> Result<()> {
15659        self.jobs.supersede(|job| matches!(job, Job::ViewPivot(_)));
15660        if let Some(saved) = &view.settings.sample {
15661            return self.apply_sampled_view(view, saved, why);
15662        }
15663        let Some(state) = self.data_table_state.as_mut() else {
15664            return Ok(());
15665        };
15666        match state.try_transition(|s| Self::replay_view(s, &view.settings, None))? {
15667            (Replayed::Planned, rollback) => {
15668                self.view_planned(view, rollback, why);
15669                Ok(())
15670            }
15671            (Replayed::Pivot(job), rollback) => {
15672                // The table stays as it is while the pivot is read.
15673                state.roll_back(rollback);
15674                // Past any load-ahead for the view on screen, whose rows must not land
15675                // in the one that replaces it.
15676                self.jobs.try_advance();
15677                let pivot_view = Job::ViewPivot(Box::new((view.clone(), why)));
15678                self.spawn_job(pivot_view, Some(Self::APPLYING_VIEW), move |_| {
15679                    let pivoted = job
15680                        .run()
15681                        .map_err(|e| crate::error_display::user_message_from_report(&e, None))?;
15682                    Ok(Answer::ViewPivoted(pivoted))
15683                });
15684                Ok(())
15685            }
15686        }
15687    }
15688
15689    /// The view's steps are planned over `rollback`, the view it replaces: mark it
15690    /// applied and read its first rows. Until they are in, a failure puts `rollback`
15691    /// back and the view marked applied before it.
15692    fn view_planned(
15693        &mut self,
15694        view: &SavedView,
15695        rollback: crate::widgets::datatable::ViewRollback,
15696        why: Option<view::MatchReason>,
15697    ) {
15698        if let Some(path) = &self.path {
15699            use crate::logging::LogFailure;
15700            self.view_manager
15701                .record_use(&view.id, path)
15702                .or_log("record a view's use");
15703        }
15704        let previous = self.active_view_id.replace(view.id.clone());
15705        self.restore_view_chart(view.settings.chart.as_ref());
15706        let Some(state) = self.data_table_state.as_ref() else {
15707            return;
15708        };
15709        self.query_running = Some(QueryRun {
15710            origin: RunOrigin::View {
15711                previous,
15712                matched: why.map(|why| (view.name.clone(), why)),
15713            },
15714            frame: state.len_generation(),
15715            rollback,
15716            len_count_inflight: self.len_count_inflight,
15717            count_after_paint: self.count_after_paint,
15718            len_count_failed: self.len_count_failed,
15719            rows: None,
15720        });
15721        if !self.spawn_async_collect(Self::APPLYING_VIEW) {
15722            // Nothing to read: the view has no rows. Applied on open, it was the
15723            // open's last step.
15724            if let Some(why) = why {
15725                self.flash_view_applied(&view.name, why);
15726            }
15727            self.query_running = None;
15728            self.busy = false;
15729            self.status_message = None;
15730            self.first_rows_settled();
15731        }
15732    }
15733
15734    /// Whether a view is being applied at the table: its pivot or its first rows are
15735    /// being read.
15736    pub(crate) fn view_applying(&self) -> bool {
15737        if !self.is_busy() || !self.in_normal_table_view() {
15738            return false;
15739        }
15740        let pivot = self
15741            .jobs
15742            .current(|job| matches!(job, Job::ViewPivot(_)))
15743            .is_some();
15744        let rows = self.query_running.as_ref().is_some_and(|run| {
15745            matches!(run.origin, RunOrigin::View { .. })
15746                && self
15747                    .data_table_state
15748                    .as_ref()
15749                    .is_some_and(|state| state.len_generation() == run.frame)
15750        });
15751        pivot || rows
15752    }
15753
15754    /// What the control bar says while a query's first rows are read over the frame on
15755    /// screen, from the read's job record. The rows drawn meanwhile are the view it
15756    /// replaces, under columns it may have changed.
15757    pub(crate) fn query_reading(&self) -> Option<&str> {
15758        let run = self.query_running.as_ref()?;
15759        let frame = self.data_table_state.as_ref()?.len_generation();
15760        if !matches!(run.origin, RunOrigin::Query(_)) || run.frame != frame {
15761            return None;
15762        }
15763        self.jobs
15764            .waiting_status(|job| Self::reading_rows(job) || Self::owed_rows(job))
15765    }
15766
15767    /// Stop applying a view and keep the one before it. As with a pivot, a worker runs
15768    /// to the end and the bump drops its answer.
15769    fn cancel_view(&mut self) {
15770        self.jobs.advance();
15771        self.screen_generation = self.screen_generation.wrapping_add(1);
15772        if let Some(run) = self.take_query_run() {
15773            self.roll_back_query_run(run);
15774        }
15775        // A collect for the view, queued behind a worker, would read it after all.
15776        self.forget_the_rows_read();
15777        self.read_after_view_rollback();
15778        self.flash_note("View cancelled".to_string());
15779    }
15780
15781    /// A job's outcome is in: take it, and the job's record with it, from [`Jobs`], and
15782    /// act on it. The record goes in this step, so the job holds the generation and
15783    /// the keys until its answer is handled and not after: whatever the answer starts
15784    /// next holds them before anything else can look.
15785    ///
15786    /// What the job held is put down here, for every job alike: a job the user waited
15787    /// on gives the keys back, and its line on the control bar goes with it, unless the
15788    /// answer goes on to a continuation, which keeps the wait up across the gap.
15789    fn job_ended(&mut self, ticket: Ticket) -> Option<AppEvent> {
15790        let jobs::Ended {
15791            job,
15792            current,
15793            keys,
15794            outcome,
15795            ..
15796        } = self.jobs.end(ticket)?;
15797        let cancelled_analysis = !current && Self::reads_for_analysis(&job);
15798        let waited = keys.is_some();
15799        let out = match outcome {
15800            Outcome::Answered(answer) => self.answered(job, current, waited, *answer),
15801            Outcome::Failed { message, panicked } => {
15802                self.background_failed(&job, current, waited, &message, panicked);
15803                None
15804            }
15805        };
15806        if let Some(status) = keys {
15807            if out.is_some() {
15808                self.busy = true;
15809            } else {
15810                self.busy = false;
15811                // Unless a job that is still running says the same: the read of a
15812                // view's rows that its pivot's answer started.
15813                if self.status_message.as_deref() == Some(status.as_str())
15814                    && !self.jobs.shows(&status)
15815                {
15816                    self.status_message = None;
15817                }
15818            }
15819        }
15820        // A cancelled analysis's worker has exited: its read is over, and once no other
15821        // is still going, Run can run again and Setup no longer says it waits.
15822        if cancelled_analysis
15823            && self.cancelled_analysis().is_none()
15824            && self.analysis_modal.data_quality_setup_note.as_deref() == Some(QUALITY_RUN_WAITS)
15825        {
15826            self.analysis_modal.data_quality_setup_note = None;
15827        }
15828        out
15829    }
15830
15831    /// A report from a job still running, taken while the job is current.
15832    fn job_progress(&mut self, ticket: Ticket, progress: &Progress) {
15833        if !self.jobs.is_current(ticket) {
15834            return;
15835        }
15836        match progress {
15837            Progress::ExportWriting { phase, bytes } => {
15838                if let Some(export) = self.export_progress.as_mut() {
15839                    export.current_phase = phase.to_string();
15840                    export.written = Some(*bytes);
15841                }
15842            }
15843            Progress::QualityPhase(phase) => {
15844                if let Some(progress) = self.analysis_modal.computing.as_mut() {
15845                    progress.phase = phase.stage.label().to_string();
15846                    progress.reads_source = Some(phase.reads_source);
15847                    progress.interruptible = Some(phase.interruptible);
15848                }
15849            }
15850            Progress::Finding { rows } => self.find_progress(*rows),
15851            Progress::HexFinding { read, total } => self.hex_find_progress(*read, *total),
15852            Progress::SampleBegun(schema) => self.sample_begun(schema),
15853            Progress::SampleGrew => self.sample_grew(),
15854        }
15855    }
15856
15857    /// `job` answered. `current` says whether its answer is still the one waited for:
15858    /// a stale one changes nothing on screen, and whatever it carries is dropped here.
15859    /// `waited` says the user was waiting on it.
15860    fn answered(
15861        &mut self,
15862        job: Job,
15863        current: bool,
15864        waited: bool,
15865        answer: Answer,
15866    ) -> Option<AppEvent> {
15867        match answer {
15868            Answer::Load(answer) => {
15869                // The open's to judge, by its own identity rather than the generation: an
15870                // answer for an open given up or replaced, or for a phase it has left,
15871                // changes nothing on screen, and what it carries — a download's file, a
15872                // dataset — is dropped with it.
15873                let Job::Load(load) = job else {
15874                    return None;
15875                };
15876                let step = self.loading.answered(
15877                    load,
15878                    *answer,
15879                    #[cfg(any(feature = "http", feature = "cloud"))]
15880                    &self.jobs,
15881                );
15882                self.run_load_step(step)
15883            }
15884            Answer::NamedPaths {
15885                paths,
15886                options,
15887                directory,
15888            } => {
15889                // The user left the open while its paths were looked at, or another took
15890                // its place.
15891                let Job::OpenNamed(load) = job else {
15892                    return None;
15893                };
15894                if !self.loading.looking_at_paths(load) {
15895                    return None;
15896                }
15897                // Either carries the same open on: it is still starting.
15898                Some(match directory {
15899                    Some(dir) => AppEvent::LookThenOpenDirectory(dir, *options),
15900                    None => AppEvent::Open(paths, *options),
15901                })
15902            }
15903            Answer::NamedPathMissing(path) => {
15904                let Job::OpenNamed(load) = job else {
15905                    return None;
15906                };
15907                if !self.loading.looking_at_paths(load) {
15908                    return None;
15909                }
15910                // The session ends saying so; nothing is opened.
15911                if let Some(retired) = self.loading.retire() {
15912                    self.put_down_load(retired);
15913                }
15914                Some(AppEvent::NamedPathMissing(path))
15915            }
15916            Answer::LookedAt {
15917                kind,
15918                holds,
15919                options,
15920            } => {
15921                // The user pressed Ctrl+O and went to the home screen, a newer look
15922                // replaced this one, or another open took its place while this was
15923                // reading. Their choice is the one on screen, and this is the answer to a
15924                // question nobody is waiting for.
15925                let Job::LookAtDirectory { load, path } = job else {
15926                    return None;
15927                };
15928                if !self.loading.looking_at_directory(load) {
15929                    return None;
15930                }
15931                // An `Open` that follows carries the same open on.
15932                self.open_the_directory_looked_at(path, kind, holds.as_deref(), *options)
15933            }
15934            Answer::Kind(found) => {
15935                // Superseded: a newer look, a trip away from home, or something that took
15936                // the screen over owns the wait, so this one touches nothing.
15937                let Job::Classify(asked) = job else {
15938                    return None;
15939                };
15940                if !current {
15941                    return None;
15942                }
15943                self.home.status = None;
15944
15945                // A key pressed on the home screen answers on the home screen. If they
15946                // went back to the data, opening now would arrive from nowhere; if the
15947                // browse has moved, the answer is about somewhere they navigated away
15948                // from, and acting on it would take them back into it.
15949                if self.input_mode != InputMode::Home || self.home.browsing != asked.browsing {
15950                    return None;
15951                }
15952
15953                let path = asked.path;
15954                let Some(kind) = found else {
15955                    self.home.status = Some(format!("No such path: {}", path.display()));
15956                    if asked.jump {
15957                        // A typo typed at `~` is worth another go without retyping it.
15958                        self.home.path_input = path.display().to_string();
15959                        self.home.path_input_active = true;
15960                        self.list_the_typed_directory();
15961                    }
15962                    return None;
15963                };
15964                self.open_what_it_is(path, kind, asked.jump)
15965            }
15966            Answer::Rows(result) => {
15967                // A stale page is dropped; the wait belongs to whatever replaced it.
15968                let Job::Rows(inflight) = job else {
15969                    return None;
15970                };
15971                if !current {
15972                    return None;
15973                }
15974                // Timed to here rather than to the next paint: this is the moment the
15975                // rows exist to be drawn, and the frame that draws them costs the same
15976                // whatever the page cost to fetch.
15977                if let Some(state) = self.data_table_state.as_ref() {
15978                    let took = inflight.began.elapsed();
15979                    log::debug!(
15980                        target: "datui",
15981                        "rows {}..{} of {}: read in {took:.1?}",
15982                        inflight.start,
15983                        inflight.end,
15984                        inflight.dataset
15985                    );
15986                    state.measurements().read_page(took, inflight.files);
15987                }
15988                if let Some(state) = &mut self.data_table_state {
15989                    state.apply_async_collect(result);
15990                }
15991                self.retire_a_count_the_rows_answered();
15992                self.remember_a_downloads_shape();
15993                // Rows a follow counted while these were read are shown next.
15994                self.catch_up_follow();
15995                // The query's first rows are in: it stands.
15996                let ran = self.take_query_run();
15997                // A load-ahead's end is nobody's wait ending: whatever else is under
15998                // way meanwhile keeps its spinner and its message.
15999                if waited {
16000                    self.first_rows_settled();
16001                    match ran.map(|run| run.origin) {
16002                        Some(RunOrigin::Query(mode)) if self.query_prompt_mode() == Some(mode) => {
16003                            self.leave_query_prompt_after_run();
16004                        }
16005                        // Shown once the wait is over, or the spinner's message hides it.
16006                        Some(RunOrigin::View {
16007                            matched: Some((name, why)),
16008                            ..
16009                        }) => self.flash_view_applied(&name, why),
16010                        _ => {}
16011                    }
16012                }
16013                None
16014            }
16015            Answer::RowsFailed {
16016                message,
16017                conversion,
16018            } => {
16019                self.rows_failed(current, waited, &message, conversion.as_deref());
16020                None
16021            }
16022            Answer::Described(results) => {
16023                if current {
16024                    self.analysis_modal.describe_results = Some(results);
16025                    self.analysis_modal.computing = None;
16026                }
16027                None
16028            }
16029            Answer::Distributions(results) => {
16030                if current {
16031                    self.analysis_modal.distribution_results = Some(results);
16032                    self.analysis_modal.computing = None;
16033                }
16034                None
16035            }
16036            Answer::Correlations(results) => {
16037                if current {
16038                    self.analysis_modal.install_correlations(results);
16039                    self.analysis_modal.computing = None;
16040                }
16041                None
16042            }
16043            Answer::DataQuality {
16044                results,
16045                kept,
16046                plan,
16047            } => {
16048                // Kept whatever became of the run's results: the rows are the rows the
16049                // key names, and a read is not to be thrown away.
16050                if let Some(kept) = kept {
16051                    self.retain_quality_sample(&kept);
16052                }
16053                if current
16054                    && self.analysis_modal.active
16055                    && self.analysis_modal.selected_tool
16056                        == Some(analysis_modal::AnalysisTool::DataQuality)
16057                {
16058                    // Labeled with the plan it was dispatched with, whatever has been
16059                    // staged since.
16060                    self.cache_quality_result(&results, (*plan).clone());
16061                    self.analysis_modal.data_quality_last_plan = Some(*plan);
16062                    self.analysis_modal.data_quality_results = Some(*results);
16063                    self.analysis_modal.data_quality_from_cache = false;
16064                    self.analysis_modal
16065                        .set_quality_page(crate::data_quality::QualityPage::Overview);
16066                    self.analysis_modal.computing = None;
16067                }
16068                None
16069            }
16070            Answer::SampleDrawn(drawn) => self.sample_drawn(job, current, drawn),
16071            Answer::Sample { df, label } => {
16072                if current {
16073                    self.analysis_modal.computing = None;
16074                    self.show_sample_view(df, label);
16075                }
16076                None
16077            }
16078            Answer::Pivoted { spec, pivoted } => {
16079                // Superseded means something replaced the view, which owns the wait.
16080                if !current {
16081                    return None;
16082                }
16083                let installed = self.data_table_state.as_mut().map(|state| {
16084                    state
16085                        .deferred(|s| s.install_pivot(&spec, pivoted))
16086                        .map_err(|e| crate::error_display::user_message_from_report(&e, None))
16087                });
16088                match installed {
16089                    Some(Ok(())) => {
16090                        self.pivot_melt_modal.close();
16091                        // Only from the modal: a trip home meanwhile stays home.
16092                        if self.input_mode == InputMode::PivotMelt {
16093                            self.input_mode = InputMode::Normal;
16094                        }
16095                        // The wait passes to the read of its rows.
16096                        self.spawn_async_collect(Self::LOADING_BUFFER);
16097                    }
16098                    Some(Err(message)) => self.error_modal.show(message),
16099                    None => {}
16100                }
16101                None
16102            }
16103            Answer::ReshapePreviewed { input, result } => {
16104                if let Job::ReshapePreview { epoch, token } = job {
16105                    self.reshape_preview_ended(epoch, token, input, result);
16106                }
16107                None
16108            }
16109            Answer::ViewPivoted(pivoted) => {
16110                // Superseded means the view was cancelled or something replaced it, which
16111                // owns the wait.
16112                let Job::ViewPivot(pivot) = job else {
16113                    return None;
16114                };
16115                let (view, why) = *pivot;
16116                if !current {
16117                    return None;
16118                }
16119                let planned = self.data_table_state.as_mut().map(|state| {
16120                    // Nothing changed while the pivot was read, so the steps before
16121                    // it plan as they did; this time the pivot is in hand.
16122                    state
16123                        .try_transition(|s| Self::replay_view(s, &view.settings, Some(pivoted)))
16124                        .map(|(_, rollback)| rollback)
16125                        .map_err(|e| e.to_string())
16126                });
16127                match planned {
16128                    // The wait passes to the read of its rows.
16129                    Some(Ok(rollback)) => self.view_planned(&view, rollback, why),
16130                    Some(Err(message)) => self.view_pivot_failed(&message),
16131                    None => {}
16132                }
16133                None
16134            }
16135            Answer::DrillRow { group_index, row } => {
16136                // Superseded means something replaced the view, which owns the wait.
16137                if current {
16138                    self.drill_into(group_index, &row);
16139                }
16140                None
16141            }
16142            Answer::FieldsRead(values) => {
16143                // Superseded means something replaced the view, which owns the wait.
16144                let Job::InspectRow { frame, row } = job else {
16145                    return None;
16146                };
16147                if !current {
16148                    return None;
16149                }
16150                let asked = self
16151                    .inspector_modal
16152                    .read
16153                    .as_ref()
16154                    .is_some_and(|read| read.key() == (frame, row));
16155                if self.inspector_modal.active && asked {
16156                    self.inspector_modal.read =
16157                        Some(inspector_modal::FieldRead::Read { frame, row, values });
16158                }
16159                None
16160            }
16161            Answer::JsonParsed(root) => {
16162                // Superseded means something replaced the view, which owns the wait.
16163                let Job::InspectJson { token } = job else {
16164                    return None;
16165                };
16166                if !current || !self.inspector_modal.active {
16167                    return None;
16168                }
16169                let modal = &mut self.inspector_modal;
16170                if let Some(wait) = modal.json_wait.take_if(|w| w.token == token) {
16171                    let node = inspector_drill::Node::Json {
16172                        root,
16173                        path: Vec::new(),
16174                    };
16175                    modal.drill_in(wait.frame, wait.row, wait.label, node);
16176                }
16177                None
16178            }
16179            Answer::Indented(text) => {
16180                let Job::InspectPretty { token } = job else {
16181                    return None;
16182                };
16183                let modal = &mut self.inspector_modal;
16184                if current
16185                    && let Some(inspector_modal::Pretty::Pending { token: t, place }) =
16186                        modal.pretty.as_ref()
16187                    && *t == token
16188                {
16189                    modal.pretty = Some(inspector_modal::Pretty::Ready {
16190                        place: place.clone(),
16191                        text,
16192                    });
16193                }
16194                None
16195            }
16196            Answer::Unpacked(decoded) => {
16197                let Job::InspectUnpack { token } = job else {
16198                    return None;
16199                };
16200                let modal = &mut self.inspector_modal;
16201                if current
16202                    && let Some(inspector_modal::Unpack::Pending { token: t, place }) =
16203                        modal.unpack.as_ref()
16204                    && *t == token
16205                {
16206                    modal.unpack = Some(inspector_modal::Unpack::Ready {
16207                        place: place.clone(),
16208                        text: std::sync::Arc::new(decoded),
16209                    });
16210                }
16211                None
16212            }
16213            Answer::ValueWritten(open) => {
16214                if current && self.inspector_modal.active {
16215                    self.external_open = Some(open);
16216                }
16217                None
16218            }
16219            Answer::Exported(path) => {
16220                // Written: the dialog held for a failure is done with.
16221                self.export_modal.close();
16222                self.export_counts = None;
16223                if current {
16224                    self.export_progress = None;
16225                    self.flash_path("Exported to ", &path);
16226                }
16227                None
16228            }
16229            Answer::Copied { payload, message } => {
16230                if current {
16231                    self.export_progress = None;
16232                    self.finish_copy(payload, message);
16233                }
16234                None
16235            }
16236            Answer::QualityReportWritten(path) => {
16237                self.analysis_modal.data_quality_export = None;
16238                if current {
16239                    self.flash_path("Report written to ", &path);
16240                }
16241                None
16242            }
16243            Answer::ChartExported => {
16244                // Leaving the chart's dataset supersedes the write: one that finishes
16245                // after Ctrl-O must not reopen its modal over the home screen.
16246                if let Job::ChartExport { path, format } = job
16247                    && current
16248                {
16249                    self.finish_chart_export(&path, format, Ok(()));
16250                }
16251                None
16252            }
16253            Answer::FileFacts(facts) => {
16254                if let Job::FileFacts { dataset } = job {
16255                    self.file_facts_landed(dataset, facts);
16256                }
16257                None
16258            }
16259            Answer::UnfitCounted(unfit) => {
16260                // Every value fitting says nothing in the Notes; the log says it ran.
16261                let columns: Vec<&str> = unfit.iter().map(|u| u.column.as_str()).collect();
16262                let said = if columns.is_empty() {
16263                    "none".to_string()
16264                } else {
16265                    columns.join(", ")
16266                };
16267                log::debug!(target: "datui", "values column types made null, by column: {said}");
16268                if let Job::UnfitCount { dataset, version } = job
16269                    && dataset == self.dataset_generation
16270                    && let Some(state) = self.data_table_state.as_mut()
16271                {
16272                    match version {
16273                        None => state.unfit_counted(&unfit),
16274                        Some(version) => state.changes_unfit_counted(version, &unfit),
16275                    }
16276                }
16277                None
16278            }
16279            Answer::Found(found) => {
16280                if let Job::Find(run) = job {
16281                    self.find_answered(run, current, found);
16282                }
16283                None
16284            }
16285            Answer::HexOpened(source) => {
16286                self.hex_opened(job, current, *source);
16287                None
16288            }
16289            Answer::HexFound(hit) => {
16290                self.hex_found(job, current, hit);
16291                None
16292            }
16293            Answer::ValueCounts(counts) => {
16294                // Superseded means the screen moved on: another column, a cancel, a
16295                // trip away.
16296                if current {
16297                    self.value_counts.computing = None;
16298                    self.value_counts.hold(*counts);
16299                }
16300                None
16301            }
16302            // What a test's answer carries goes with it.
16303            #[cfg(test)]
16304            Answer::Probe(held) => {
16305                drop(held);
16306                None
16307            }
16308        }
16309    }
16310
16311    /// Put down what a failed background operation started, and say why.
16312    ///
16313    /// The keys and the line it held are put down by [`Self::job_ended`]. Each arm
16314    /// clears only what the job itself started, and only when the job is current: a
16315    /// load-ahead that dies leaves the analysis beside it running, and an older look at
16316    /// a path leaves the newer one waiting. One that is not current is dropped.
16317    fn background_failed(
16318        &mut self,
16319        job: &Job,
16320        current: bool,
16321        waited: bool,
16322        message: &str,
16323        panicked: bool,
16324    ) {
16325        match job {
16326            // Judged by the open, as its answers are: one put down or replaced is not the
16327            // open the user is waiting on.
16328            Job::Load(load) | Job::OpenNamed(load) | Job::LookAtDirectory { load, .. } => {
16329                if let loading::Step::Failed(failed) = self.loading.failed(*load, message) {
16330                    self.load_failed(failed);
16331                }
16332            }
16333            Job::Classify(_) => {
16334                if current {
16335                    self.home.status = None;
16336                    self.error_modal.show(message.to_string());
16337                }
16338            }
16339            Job::Rows(_) | Job::OwedRows { .. } => self.rows_failed(current, waited, message, None),
16340            Job::Analysis(_) | Job::SampleRows => {
16341                if current {
16342                    self.analysis_modal.computing = None;
16343                    self.error_modal.show(message.to_string());
16344                }
16345            }
16346            Job::SampleDraw(_) => self.sample_draw_failed(job, current, message),
16347            // The form stays up with its spec, to be fixed.
16348            Job::Pivot | Job::Copy => {
16349                if current {
16350                    self.error_modal.show(message.to_string());
16351                }
16352            }
16353            // The dialog is still up, the reason on its status line under the path.
16354            Job::QualityReport => {
16355                if current {
16356                    match self.analysis_modal.data_quality_export.as_mut() {
16357                        Some(form) => form.error = Some(message.to_string()),
16358                        None => self.error_modal.show(message.to_string()),
16359                    }
16360                }
16361            }
16362            Job::ViewPivot(_) => {
16363                if current {
16364                    self.view_pivot_failed(message);
16365                }
16366            }
16367            // The preview says why in its own pane; the log has a panic's details.
16368            Job::ReshapePreview { epoch, token } => {
16369                let message = if panicked {
16370                    "Could not preview; see the log".to_string()
16371                } else {
16372                    message.to_string()
16373                };
16374                self.reshape_preview_ended(*epoch, *token, None, Err(message));
16375            }
16376            Job::DrillRow => {
16377                // The grouped view stays as it was. A flash has one line, and a panic's
16378                // message is an internal error with the log's path under it: the log
16379                // has the details.
16380                if current {
16381                    self.flash_note(if panicked {
16382                        "Could not drill in; see the log".to_string()
16383                    } else {
16384                        format!("Could not drill in: {message}")
16385                    });
16386                }
16387            }
16388            Job::InspectJson { token } => {
16389                let modal = &mut self.inspector_modal;
16390                if current && let Some(wait) = modal.json_wait.take_if(|w| w.token == *token) {
16391                    modal.not_json = Some((wait.frame, wait.row, wait.path));
16392                    self.flash_note(if panicked {
16393                        "Could not read the JSON; see the log".to_string()
16394                    } else {
16395                        sentence(message)
16396                    });
16397                }
16398            }
16399            Job::InspectPretty { token } => {
16400                let modal = &mut self.inspector_modal;
16401                if let Some(inspector_modal::Pretty::Pending { token: t, place }) =
16402                    modal.pretty.as_ref()
16403                    && t == token
16404                {
16405                    modal.pretty = Some(inspector_modal::Pretty::Failed {
16406                        place: place.clone(),
16407                    });
16408                }
16409            }
16410            Job::InspectUnpack { token } => {
16411                let modal = &mut self.inspector_modal;
16412                if let Some(inspector_modal::Unpack::Pending { token: t, place }) =
16413                    modal.unpack.as_ref()
16414                    && t == token
16415                {
16416                    modal.unpack = Some(inspector_modal::Unpack::Failed {
16417                        place: place.clone(),
16418                    });
16419                }
16420            }
16421            Job::OpenValue => {
16422                if current {
16423                    self.flash_note(if panicked {
16424                        "Could not open the value; see the log".to_string()
16425                    } else {
16426                        format!("Could not open the value: {message}")
16427                    });
16428                }
16429            }
16430            Job::InspectRow { frame, row } => {
16431                if current {
16432                    let asked = self
16433                        .inspector_modal
16434                        .read
16435                        .as_ref()
16436                        .is_some_and(|read| read.key() == (*frame, *row));
16437                    if asked {
16438                        // The pane has room for the reason; a panic's is the log's.
16439                        let message = if panicked {
16440                            "Could not read the field; see the log".to_string()
16441                        } else {
16442                            format!("Could not read the field: {message}")
16443                        };
16444                        self.inspector_modal.read = Some(inspector_modal::FieldRead::Failed {
16445                            frame: *frame,
16446                            row: *row,
16447                            message,
16448                        });
16449                    }
16450                }
16451            }
16452            // The form comes back as it was, the reason on its status line, to fix
16453            // the path and press Enter again.
16454            Job::Export => {
16455                if current {
16456                    self.export_progress = None;
16457                    self.export_modal.resume();
16458                    self.export_modal.path_error = Some(message.to_string());
16459                    self.input_mode = InputMode::Export;
16460                } else {
16461                    self.export_modal.close();
16462                    self.export_counts = None;
16463                }
16464            }
16465            Job::ChartExport { path, format } => {
16466                if current {
16467                    self.finish_chart_export(path, *format, Err(message.to_string()));
16468                }
16469            }
16470            Job::Find(_) => self.find_failed(current, message),
16471            Job::HexOpen { .. } => {
16472                if current {
16473                    self.error_modal.show(message.to_string());
16474                }
16475            }
16476            Job::HexFind(_) => {
16477                if current {
16478                    self.status_message = None;
16479                    self.flash_note(message.to_string());
16480                }
16481            }
16482            Job::ValueCounts => {
16483                // Said on the screen, in place of the counts.
16484                if current && let Some(computing) = self.value_counts.computing.take() {
16485                    let why = if panicked {
16486                        "could not count; see the log".to_string()
16487                    } else {
16488                        message.to_string()
16489                    };
16490                    self.value_counts.failed = Some((computing.column, why));
16491                }
16492            }
16493            // Judged by the dataset, as its answer is.
16494            Job::FileFacts { dataset } => {
16495                // The panel has one line for it, and a panic's message is an internal
16496                // error with the log's path under it.
16497                let why = if panicked {
16498                    "could not read; see the log".to_string()
16499                } else {
16500                    message.to_string()
16501                };
16502                self.file_facts_landed(*dataset, FileFacts::Failed(why));
16503            }
16504            // The note is left unsaid; the log has why.
16505            Job::UnfitCount { .. } => {
16506                log::warn!(target: "datui", "counting values that did not fit their type failed: {message}");
16507            }
16508        }
16509    }
16510
16511    /// Ask a worker for the open file's size and footer, unless this dataset has
16512    /// already asked. A source with no file on this machine has none to ask for.
16513    ///
16514    /// No lease and no busy state. The answer is judged by `dataset_generation`, which
16515    /// a bump does not change, so a bump cannot strand it; leased, a slow stat would
16516    /// hold the next buffer collect behind it. And busy would hold the keys typed at
16517    /// the panel, Esc included, behind a read the panel already says it is waiting on.
16518    fn read_file_facts(&mut self) {
16519        let dataset = self.dataset_generation;
16520        if self.data_table_state.is_none()
16521            || self
16522                .file_facts
16523                .as_ref()
16524                .is_some_and(|(read, _)| *read == dataset)
16525            || self.file_facts_reading()
16526        {
16527            return;
16528        }
16529        // One file on this machine, or nothing: a glob is no file to stat, and several
16530        // files are not the first one's size.
16531        let several = self
16532            .opened
16533            .as_ref()
16534            .is_some_and(|(paths, _)| paths.len() > 1);
16535        let piped = self.reads_stdin();
16536        let Some(path) = self.path.clone().filter(|path| {
16537            !several
16538                && !piped
16539                && !source::is_remote_url(path)
16540                && !source::is_prefix_or_glob(&path.to_string_lossy())
16541        }) else {
16542            return;
16543        };
16544        let facts = self.facts_of_open().map(|(_, facts)| facts);
16545        #[cfg(test)]
16546        let read: FileFactsReader = self
16547            .file_facts_reader
16548            .clone()
16549            .unwrap_or_else(|| Arc::new(FileFacts::read));
16550        #[cfg(not(test))]
16551        let read = FileFacts::read;
16552        self.spawn_job(Job::FileFacts { dataset }, None, move |_| {
16553            Ok(Answer::FileFacts(read(&path, facts)?))
16554        });
16555    }
16556
16557    /// Count, behind the Info panel, the values the read's column types made null, for
16558    /// the Notes: one pass over the frame before the types, the first time the panel
16559    /// opens on a dataset with typed columns.
16560    fn count_unfit(&mut self) {
16561        let dataset = self.dataset_generation;
16562        let Some(state) = self.data_table_state.as_ref() else {
16563            return;
16564        };
16565        let read = state
16566            .unfit_to_count()
16567            .map(|(source, typed)| (source, typed, None));
16568        let view = state
16569            .changes_unfit_to_count()
16570            .map(|(source, typed, version)| (source, typed, Some(version)));
16571        let streaming = self.app_config.performance.streaming;
16572        for (source, typed, version) in [read, view].into_iter().flatten() {
16573            let running = self
16574                .jobs
16575                .current(|job| {
16576                    matches!(job, Job::UnfitCount { dataset: d, version: v }
16577                        if *d == dataset && *v == version)
16578                })
16579                .is_some();
16580            if running {
16581                continue;
16582            }
16583            self.spawn_job(Job::UnfitCount { dataset, version }, None, move |_| {
16584                let counted = crate::statistics::collect_lazy(
16585                    crate::column_types::unfit_frame(source, &typed),
16586                    streaming,
16587                )
16588                .map_err(|e| crate::error_display::user_message_from_polars(&e))?;
16589                Ok(Answer::UnfitCounted(crate::column_types::unfit_counts(
16590                    &counted, &typed,
16591                )))
16592            });
16593        }
16594    }
16595
16596    /// Whether the values the read's column types made null are being counted.
16597    pub fn unfit_count_pending(&self) -> bool {
16598        self.jobs
16599            .current(|job| matches!(job, Job::UnfitCount { .. }))
16600            .is_some()
16601    }
16602
16603    /// Whether the open dataset's file facts are being read.
16604    pub(crate) fn file_facts_reading(&self) -> bool {
16605        let dataset = self.dataset_generation;
16606        self.jobs
16607            .current(|job| matches!(job, Job::FileFacts { dataset: asked } if *asked == dataset))
16608            .is_some()
16609    }
16610
16611    /// The file facts read for `dataset`, kept if that is still the dataset on screen.
16612    /// An answer for one replaced since is about a file no longer there.
16613    fn file_facts_landed(&mut self, dataset: u64, facts: FileFacts) {
16614        if dataset == self.dataset_generation {
16615            self.file_facts = Some((dataset, facts));
16616        }
16617    }
16618
16619    /// What the Info panel knows about the open file: `None` until it is asked, and
16620    /// for a source with no file on this machine. Installing a dataset clears it, so
16621    /// what is here is the open dataset's.
16622    pub fn file_facts(&self) -> Option<&FileFacts> {
16623        Self::facts_shown(
16624            &self.file_facts,
16625            self.dataset_generation,
16626            self.file_facts_reading(),
16627        )
16628    }
16629
16630    /// [`Self::file_facts`], from the fields it reads, for a caller holding the rest
16631    /// of the app.
16632    pub(crate) fn facts_shown(
16633        read: &Option<(u64, FileFacts)>,
16634        dataset: u64,
16635        reading: bool,
16636    ) -> Option<&FileFacts> {
16637        static READING: FileFacts = FileFacts::Reading;
16638        match read {
16639            Some((read_for, facts)) if *read_for == dataset => Some(facts),
16640            _ => reading.then_some(&READING),
16641        }
16642    }
16643
16644    /// An open found a database of several tables: the home screen lists them, as it
16645    /// lists a directory of separate tables.
16646    fn land_on_tables(&mut self, tables: loading::Tables) {
16647        let loading::Tables {
16648            database,
16649            from_home,
16650        } = tables;
16651        self.status_message = None;
16652        self.busy = false;
16653        self.enter_home();
16654        if from_home {
16655            self.home_browse_into(database);
16656        } else {
16657            self.home_jump_into(database);
16658        }
16659    }
16660
16661    /// An open failed before its first rows; the loader has put it down. The dataset
16662    /// already up is the current one again, or the home screen is, when that is where
16663    /// the open was chosen.
16664    fn load_failed(&mut self, failed: loading::Failed) {
16665        let loading::Failed { message, from_home } = failed;
16666        self.status_message = None;
16667        self.busy = false;
16668        // Kept so the home screen can say why, if dismissing the error lands the user
16669        // there from a command line that named the file. Chosen at home, the dialog
16670        // has said it, and the prompt's line saying it again was the same failure
16671        // reported twice (#547 D8).
16672        if from_home {
16673            self.last_load_error = None;
16674            self.enter_home();
16675        } else {
16676            self.last_load_error = Some(message.clone());
16677        }
16678        self.error_modal.show(message);
16679    }
16680
16681    /// The table's rows could not be read. A query or view waiting on them is not
16682    /// applied (#400, #432); a page the table waited on ends the wait with the reason;
16683    /// a load-ahead's failure is left for the page that needs those rows.
16684    fn rows_failed(
16685        &mut self,
16686        current: bool,
16687        waited: bool,
16688        message: &str,
16689        conversion: Option<&crate::error_display::ConversionFailure>,
16690    ) {
16691        if !current {
16692            return;
16693        }
16694        let message = &self.named_by_source(message);
16695        if let Some(run) = self.take_query_run() {
16696            self.fail_query_run(run, message, conversion);
16697            return;
16698        }
16699        if !waited {
16700            return;
16701        }
16702        // A count waiting for this page to paint would read the frame that just
16703        // failed to: it fails with it, the way a count riding in the collect does.
16704        if let Some(generation) = self.count_after_paint.take() {
16705            if self.len_count_inflight == Some(generation) {
16706                self.len_count_inflight = None;
16707            }
16708            if self
16709                .data_table_state
16710                .as_ref()
16711                .is_some_and(|state| state.len_generation() == generation)
16712            {
16713                self.len_count_failed = Some(generation);
16714            }
16715        }
16716        self.first_rows_settled();
16717        self.error_modal.show(message.to_string());
16718    }
16719
16720    /// `message` with the temporary files the dataset on screen reads (a download, a
16721    /// decompressed copy) called by what the user opened.
16722    fn named_by_source(&self, message: &str) -> String {
16723        let (Some(state), Some(source)) = (self.data_table_state.as_ref(), self.path.as_deref())
16724        else {
16725            return message.to_string();
16726        };
16727        state
16728            .temp_files()
16729            .into_iter()
16730            .fold(message.to_string(), |message, file| {
16731                crate::error_display::named_by_source(&message, file, source)
16732            })
16733    }
16734
16735    /// A view's pivot could not be read or planned: the view before it stays.
16736    fn view_pivot_failed(&mut self, message: &str) {
16737        self.error_modal
16738            .show(format!("Error applying view: {message}"));
16739        self.read_after_view_rollback();
16740    }
16741
16742    /// A query or view whose first rows could not be read is not applied: put back
16743    /// what it replaced and say why where its origin says to.
16744    fn fail_query_run(
16745        &mut self,
16746        run: QueryRun,
16747        message: &str,
16748        conversion: Option<&crate::error_display::ConversionFailure>,
16749    ) {
16750        let rows = run.rows;
16751        let origin = self.roll_back_query_run(run);
16752        self.first_rows_settled();
16753        self.status_message = None;
16754        self.busy = false;
16755        // Run from the prompt, the reason goes under the query, which stays open to
16756        // be fixed. Sent any other way — a view applied — there is nothing to edit,
16757        // and the error modal says why.
16758        let mode = match origin {
16759            RunOrigin::View { .. } => {
16760                self.error_modal
16761                    .show(format!("Error applying view: {message}"));
16762                self.read_after_view_rollback();
16763                return;
16764            }
16765            RunOrigin::Query(mode) if self.query_prompt_mode() == Some(mode) => mode,
16766            RunOrigin::Query(_) => {
16767                self.error_modal.show(message.to_string());
16768                return;
16769            }
16770        };
16771        let sql = mode == QueryMode::Sql;
16772        self.query_run_error = Some(match conversion {
16773            Some(failure) if sql => failure.sql_message(rows),
16774            _ => message.to_string(),
16775        });
16776        self.inline_failures = self.inline_failures.wrapping_add(1);
16777    }
16778
16779    /// Put back the view a running query or view replaced, with its row count, and
16780    /// return where the query came from.
16781    fn roll_back_query_run(&mut self, run: QueryRun) -> RunOrigin {
16782        if let Some(state) = self.data_table_state.as_mut() {
16783            state.roll_back(run.rollback);
16784        }
16785        self.len_count_inflight = run.len_count_inflight;
16786        self.count_after_paint = run.count_after_paint;
16787        self.len_count_failed = run.len_count_failed;
16788        if let RunOrigin::View { previous, .. } = &run.origin {
16789            self.active_view_id = previous.clone();
16790        }
16791        run.origin
16792    }
16793
16794    /// The view before a failed or cancelled one is back: read its rows if it has none
16795    /// on hand, as when the view was applied on open, else stop being busy.
16796    fn read_after_view_rollback(&mut self) {
16797        self.busy = false;
16798        self.status_message = None;
16799        if !self.spawn_async_collect(Self::LOADING_BUFFER) {
16800            self.first_rows_settled();
16801        }
16802    }
16803
16804    /// Run a view's steps on `state` in the order they were built. With a pivot or melt:
16805    /// the query, filters and sort it ran over, the reshape, then the query, filters and
16806    /// sort on its result. Without one: the query, filters and sort. Column order last.
16807    /// Stops at the first step that fails, and at a pivot unless `pivoted` holds it.
16808    fn replay_view(
16809        state: &mut DataTableState,
16810        settings: &view::ViewSettings,
16811        pivoted: Option<DataFrame>,
16812    ) -> Result<Replayed> {
16813        if settings.pivot.is_some() || settings.melt.is_some() {
16814            if let Some(source) = &settings.reshape_source {
16815                Self::replay_query(
16816                    state,
16817                    source.sql_query.as_deref(),
16818                    source.query.as_deref(),
16819                    source.fuzzy_query.as_deref(),
16820                )?;
16821                Self::replay_filters_and_sort(
16822                    state,
16823                    &source.filters,
16824                    &source.sort_columns,
16825                    source.sort_directions(),
16826                )?;
16827            }
16828            let reshaped = match (&settings.pivot, &settings.melt, pivoted) {
16829                (Some(spec), _, Some(pivoted)) => state.install_pivot(spec, pivoted),
16830                (Some(spec), _, None) => {
16831                    Self::check_plan(state)?;
16832                    return Ok(Replayed::Pivot(Box::new(state.plan_pivot(spec))));
16833                }
16834                (None, Some(spec), _) => state.melt(spec),
16835                (None, None, _) => Ok(()),
16836            };
16837            reshaped.map_err(|e| {
16838                color_eyre::eyre::eyre!(
16839                    "{}",
16840                    crate::error_display::user_message_from_report(&e, None)
16841                )
16842            })?;
16843        }
16844        Self::replay_query(
16845            state,
16846            settings.sql_query.as_deref(),
16847            settings.query.as_deref(),
16848            settings.fuzzy_query.as_deref(),
16849        )?;
16850        // Before the filters, which may compare in the types it gives.
16851        if !settings.columns.is_empty() {
16852            state.set_column_changes(&settings.columns);
16853        }
16854        Self::replay_filters_and_sort(
16855            state,
16856            &settings.filters,
16857            &settings.sort_columns,
16858            settings.sort_directions(),
16859        )?;
16860        if !settings.column_order.is_empty() {
16861            state.set_column_order(settings.column_order.clone());
16862            state.set_locked_columns(settings.locked_columns_count);
16863        }
16864        Self::check_plan(state)?;
16865        Ok(Replayed::Planned)
16866    }
16867
16868    /// Whether the frame the steps so far built can be read, by its plan alone.
16869    fn check_plan(state: &DataTableState) -> Result<()> {
16870        state.check_plan().map_err(|e| {
16871            color_eyre::eyre::eyre!("{}", crate::error_display::user_message_from_polars(&e))
16872        })
16873    }
16874
16875    /// A view's query: SQL or q (at most one is stored), then a Text query.
16876    fn replay_query(
16877        state: &mut DataTableState,
16878        sql: Option<&str>,
16879        dsl: Option<&str>,
16880        fuzzy: Option<&str>,
16881    ) -> Result<()> {
16882        let stated = |q: Option<&str>| q.filter(|q| !q.trim().is_empty()).map(str::to_string);
16883        if let Some(sql) = stated(sql) {
16884            state.sql_query(sql);
16885        } else if let Some(query) = stated(dsl) {
16886            state.query(query);
16887        }
16888        if state.error().is_none()
16889            && let Some(fuzzy) = stated(fuzzy)
16890        {
16891            state.fuzzy_search(fuzzy);
16892        }
16893        match state.error().cloned() {
16894            Some(error) => Err(color_eyre::eyre::eyre!(
16895                "{}",
16896                crate::error_display::user_message_from_polars(&error)
16897            )),
16898            None => Ok(()),
16899        }
16900    }
16901
16902    /// A view's sidebar filters, then its sort.
16903    fn replay_filters_and_sort(
16904        state: &mut DataTableState,
16905        filters: &[FilterStatement],
16906        sort_columns: &[String],
16907        descending: Vec<bool>,
16908    ) -> Result<()> {
16909        if !filters.is_empty() {
16910            state.filter(filters.to_vec());
16911            if let Some(error) = state.error().cloned() {
16912                return Err(color_eyre::eyre::eyre!("{}", error));
16913            }
16914        }
16915        if !sort_columns.is_empty() {
16916            state.sort_by(sort_columns.to_vec(), descending);
16917            if let Some(error) = state.error().cloned() {
16918                return Err(color_eyre::eyre::eyre!("{}", error));
16919            }
16920        }
16921        Ok(())
16922    }
16923
16924    /// What the status line says while an export writes its file.
16925    fn export_write_phase(request: &ExportRequest) -> &'static str {
16926        if request.options.compression(request.format).is_some() {
16927            "Writing and compressing file"
16928        } else {
16929            "Writing file"
16930        }
16931    }
16932
16933    /// What the error modal says when writing an export, report or chart fails.
16934    /// Why an export did not write, for the dialog's status line, which sits under
16935    /// the path it is about.
16936    fn format_export_error(error: &color_eyre::eyre::Report) -> String {
16937        use std::io::{self, ErrorKind};
16938
16939        for cause in error.chain() {
16940            if let Some(io_err) = cause.downcast_ref::<io::Error>() {
16941                // Matched by type, not kind: an encoder's own errors share
16942                // kinds such as InvalidInput with the destination checks.
16943                let msg = match (crate::output_file::Refused::of(io_err), io_err.kind()) {
16944                    (Some(refused), _) => format!("{refused}."),
16945                    // A CSV open in a spreadsheet app, on Windows.
16946                    (None, _) if crate::error_display::held_by_another_program(io_err) => {
16947                        "it is open in another program; close it there and try again.".to_string()
16948                    }
16949                    (None, ErrorKind::PermissionDenied) => "permission denied.".to_string(),
16950                    (None, ErrorKind::IsADirectory) => "it is a directory.".to_string(),
16951                    (None, _) => crate::error_display::user_message_from_io(io_err, None),
16952                };
16953                return format!("Cannot write: {msg}");
16954            }
16955            if let Some(pe) = cause.downcast_ref::<polars::prelude::PolarsError>() {
16956                let msg = crate::error_display::user_message_from_polars(pe);
16957                return format!("Export failed: {}", msg);
16958            }
16959        }
16960        let error_str = error.to_string();
16961        let first_line = error_str.lines().next().unwrap_or("Unknown error").trim();
16962        format!("Export failed: {}", first_line)
16963    }
16964
16965    /// Above this estimated size a table copy asks first: most paste targets
16966    /// choke long before it, and the clipboard holds the whole thing at once.
16967    const COPY_CONFIRM_BYTES: usize = 10 * 1024 * 1024;
16968    /// Above this a table copy is refused outright; a file is the medium for
16969    /// data this size, and export writes one without holding it all in text.
16970    const COPY_REFUSE_BYTES: usize = 200 * 1024 * 1024;
16971
16972    /// Enter in the copy dialog: the synchronous scopes copy from the buffer
16973    /// and flash; the table scope guards on size, then collects off-thread.
16974    fn perform_copy(&mut self) -> Option<AppEvent> {
16975        use copy_modal::{CopyScope, thousands};
16976        /// What Enter decided, worked out under the table borrow and acted on
16977        /// after it: writing to the clipboard needs the whole app back.
16978        enum Planned {
16979            Copy(clipboard::Payload, String),
16980            Collect,
16981            /// None: the size is not known (the row count is still coming, or a
16982            /// binary column's width is known to no footer).
16983            Confirm(Option<usize>),
16984        }
16985        let format = self.copy_modal.format;
16986        let header = self.copy_modal.header();
16987        let scope = self.copy_modal.scope;
16988        // What the destination takes decides what is built: no HTML flavor for one
16989        // that cannot offer it, and no copy past its cap.
16990        let accepts = match self.copy_destination() {
16991            Ok(destination) => destination.accepts(),
16992            Err(e) => {
16993                self.copy_modal.close();
16994                self.input_mode = InputMode::Normal;
16995                self.error_modal.show(e);
16996                return None;
16997            }
16998        };
16999        let planned: Result<Planned, String> = match self.data_table_state.as_ref() {
17000            None => Err("Nothing to copy: no table is open".to_string()),
17001            Some(state) => match scope {
17002                CopyScope::Cell => {
17003                    let column = self.copy_modal.column.clone().unwrap_or_default();
17004                    match state.copy_cell_value(&column) {
17005                        Some(value) => {
17006                            let row = state.selected_display_row().unwrap_or(0);
17007                            Ok(Planned::Copy(
17008                                clipboard::Payload::text(value),
17009                                format!("Copied cell {column} of row {}", thousands(row)),
17010                            ))
17011                        }
17012                        None => Err("Nothing to copy: the current row is not buffered".to_string()),
17013                    }
17014                }
17015                CopyScope::Row => match state.copy_row_df() {
17016                    Some(df) => clipboard::tabular_payload(&df, format, header, accepts.html).map(
17017                        |payload| {
17018                            let row = state.selected_display_row().unwrap_or(0);
17019                            Planned::Copy(
17020                                payload,
17021                                format!("Copied row {} as {}", thousands(row), format.as_str()),
17022                            )
17023                        },
17024                    ),
17025                    None => Err("Nothing to copy: the current row is not buffered".to_string()),
17026                },
17027                CopyScope::View => match state.copy_view_df() {
17028                    Some(df) => clipboard::tabular_payload(&df, format, header, accepts.html).map(
17029                        |payload| {
17030                            Planned::Copy(
17031                                payload,
17032                                format!(
17033                                    "Copied {} rows as {}",
17034                                    thousands(df.height()),
17035                                    format.as_str()
17036                                ),
17037                            )
17038                        },
17039                    ),
17040                    None => Err("Nothing to copy: no rows are on screen".to_string()),
17041                },
17042                CopyScope::Python => Ok(Planned::Copy(
17043                    clipboard::Payload::text(self.python_script(state)),
17044                    "Copied the view as Python".to_string(),
17045                )),
17046                CopyScope::Table => {
17047                    // A capped destination's copy is read only as far as its cap, so
17048                    // what could be held is the smaller of the two.
17049                    let cap = accepts
17050                        .base64_limit
17051                        .map_or(usize::MAX, |limit| limit / 4 * 3);
17052                    match state.estimated_copy_bytes() {
17053                        Some(bytes) if bytes > Self::COPY_REFUSE_BYTES => Err(format!(
17054                            "The table is about {} — too much to hold on a clipboard. \
17055                             Export it to a file instead (e).",
17056                            Self::format_bytes(bytes as u64)
17057                        )),
17058                        Some(bytes) if bytes.min(cap) > Self::COPY_CONFIRM_BYTES => {
17059                            Ok(Planned::Confirm(Some(bytes)))
17060                        }
17061                        Some(_) => Ok(Planned::Collect),
17062                        None if cap <= Self::COPY_CONFIRM_BYTES => Ok(Planned::Collect),
17063                        // The row count has not landed yet, or a binary column's width
17064                        // is unknown, so the size is anyone's guess: ask before
17065                        // collecting an unknown amount.
17066                        None => Ok(Planned::Confirm(None)),
17067                    }
17068                }
17069            },
17070        };
17071        self.copy_modal.close();
17072        self.input_mode = InputMode::Normal;
17073        match planned {
17074            Ok(Planned::Copy(payload, message)) => {
17075                self.finish_copy(payload, message);
17076                None
17077            }
17078            Ok(Planned::Collect) => Some(AppEvent::CopyTable { format, header }),
17079            Ok(Planned::Confirm(bytes)) => {
17080                self.pending_copy = Some((format, header));
17081                let counting = self
17082                    .data_table_state
17083                    .as_ref()
17084                    .is_some_and(|state| state.num_rows_if_valid().is_none());
17085                self.confirmation_modal.show(match bytes {
17086                    Some(bytes) => format!(
17087                        "This copies about {} to the clipboard.\n\nCopy the whole table?",
17088                        Self::format_bytes(bytes as u64)
17089                    ),
17090                    None if counting => "The table's size is not known yet — the row count \
17091                                         is still being read.\n\nCopy the whole table anyway?"
17092                        .to_string(),
17093                    None => "The size of the table's binary columns is not known.\n\n\
17094                             Copy the whole table anyway?"
17095                        .to_string(),
17096                });
17097                None
17098            }
17099            Err(message) => {
17100                self.error_modal.show(message);
17101                None
17102            }
17103        }
17104    }
17105
17106    /// The view on screen as a Python Polars script: the open's reader, then every
17107    /// step that made the view. See [`python_script`].
17108    pub fn python_script(&self, state: &DataTableState) -> String {
17109        let (paths, options) = match &self.opened {
17110            Some((paths, options)) => (Some(paths.as_slice()), options.clone()),
17111            None => (None, OpenOptions::default()),
17112        };
17113        let cloud = options.effective_cloud(&self.app_config.cloud);
17114        let mut options = options;
17115        if options.read_python.is_empty() {
17116            // A decompressed file is read into its dataset directly, not through a scan.
17117            options.read_python = state.read_python().to_vec();
17118        }
17119        // The source an `s3://<id>@bucket` URL names has its own endpoint and region.
17120        let remote = paths
17121            .and_then(|paths| paths.first())
17122            .map(|p| p.to_string_lossy().into_owned())
17123            .filter(|p| source::is_remote_url(Path::new(p)));
17124        let source_of = remote
17125            .as_deref()
17126            .and_then(|url| source::split_source_id(url).0)
17127            .and_then(|id| cloud.connections.iter().find(|c| c.name == id));
17128        // What this session learned of the place: read unsigned, it is public.
17129        #[cfg(feature = "cloud")]
17130        let unsigned = remote
17131            .as_deref()
17132            .and_then(crate::cloud_sources::known_access)
17133            .unwrap_or(false);
17134        #[cfg(not(feature = "cloud"))]
17135        let unsigned = false;
17136        let record = python_script::OpenRecord {
17137            paths,
17138            options: &options,
17139            format: state.read_as().or(options.format),
17140            read_mode: state.read_mode(),
17141            schema: state.source_schema(),
17142            remote_objects: state
17143                .remote_objects()
17144                .unwrap_or_default()
17145                .into_iter()
17146                .map(|object| object.url)
17147                .collect(),
17148            s3_endpoint: source_of
17149                .and_then(|c| c.endpoint_url.clone())
17150                .or(cloud.s3_endpoint_url.clone())
17151                .filter(|s| !s.trim().is_empty()),
17152            s3_region: source_of
17153                .and_then(|c| c.region.clone())
17154                .or(cloud.s3_region.clone())
17155                .filter(|s| !s.trim().is_empty()),
17156            unsigned,
17157            read_as_text: state.read_as_text().iter().map(|c| c.to_string()).collect(),
17158            spec: state.format_read().map(|read| read.spec.name.clone()),
17159        };
17160        python_script::Script {
17161            source: python_script::source(&record),
17162            steps: state.python_steps(),
17163        }
17164        .render()
17165    }
17166
17167    /// Hand a payload to the clipboard destination, building the destination
17168    /// at the first copy, and flash or raise the error modal — a copy that
17169    /// silently did nothing would be worse than one that failed out loud.
17170    fn finish_copy(&mut self, payload: clipboard::Payload, message: String) {
17171        let written = self
17172            .copy_destination()
17173            .and_then(|destination| destination.write(payload));
17174        match written {
17175            Ok(()) => self.flash_note(message),
17176            Err(e) => self.error_modal.show(e),
17177        }
17178    }
17179
17180    /// The clipboard destination, built at the first copy.
17181    fn copy_destination(&mut self) -> Result<&mut dyn clipboard::Destination, String> {
17182        if self.clipboard.is_none() {
17183            let choice = clipboard::BackendChoice::parse(&self.app_config.clipboard.backend)
17184                .unwrap_or_default();
17185            let limit = usize::try_from(self.app_config.clipboard.osc52_limit.bytes())
17186                .unwrap_or(usize::MAX);
17187            self.clipboard = Some(clipboard::destination(choice, limit)?);
17188        }
17189        Ok(self
17190            .clipboard
17191            .as_deref_mut()
17192            .expect("destination just built"))
17193    }
17194
17195    /// Whether Enter at the table opens the inspector, as Space does: there is a table,
17196    /// and it is not one whose rows drill into groups (a `by` view, a SQL GROUP BY).
17197    /// Inside a drill-down there is nothing further to drill into either.
17198    pub fn enter_inspects(&self) -> bool {
17199        self.input_mode == InputMode::Normal
17200            && self
17201                .data_table_state
17202                .as_ref()
17203                .is_some_and(|state| !state.can_drill_down())
17204    }
17205
17206    /// Space at the table, and Enter where there is nothing to drill into: the
17207    /// inspector over the selected row.
17208    fn open_inspector(&mut self) {
17209        let Some(state) = self.data_table_state.as_ref() else {
17210            return;
17211        };
17212        if state.inspect_row().is_none() {
17213            self.flash_note("No row to inspect".to_string());
17214            return;
17215        }
17216        self.inspector_modal
17217            .open(state.inspect_fields(), state.current_column());
17218        self.input_mode = InputMode::Inspect;
17219    }
17220
17221    /// `F` at the table: Value Counts for the column cursor's column.
17222    fn open_value_counts(&mut self) {
17223        let Some(state) = self.data_table_state.as_ref() else {
17224            return;
17225        };
17226        let names = state.get_column_order().to_vec();
17227        let Some(at) = state
17228            .current_column()
17229            .and_then(|current| names.iter().position(|n| n == current))
17230        else {
17231            return;
17232        };
17233        self.value_counts.open(names, at, state.len_generation());
17234        self.input_mode = InputMode::ValueCounts;
17235        self.count_values(false);
17236    }
17237
17238    /// Whether the Value Counts screen is up: on its own, or under the export
17239    /// dialog writing its counts.
17240    pub(crate) fn value_counts_shown(&self) -> bool {
17241        self.input_mode == InputMode::ValueCounts
17242            || (self.input_mode == InputMode::Export && self.export_counts.is_some())
17243    }
17244
17245    /// Whether a count for the Value Counts screen is being read while it is up.
17246    pub(crate) fn value_counts_computing(&self) -> bool {
17247        self.input_mode == InputMode::ValueCounts && self.value_counts.computing.is_some()
17248    }
17249
17250    /// Where the export dialog goes back to: Value Counts when it is writing them.
17251    fn export_returns_to(&self) -> InputMode {
17252        if self.export_counts.is_some() {
17253            InputMode::ValueCounts
17254        } else {
17255            InputMode::Normal
17256        }
17257    }
17258
17259    /// Count the column on the Value Counts screen, unless its counts are already
17260    /// held: quickly, or every row when `exact`. Off the UI thread, without holding
17261    /// the keys, so stepping to another column or Esc stops it.
17262    fn count_values(&mut self, exact: bool) {
17263        let Some(column) = self.value_counts.column().map(str::to_string) else {
17264            return;
17265        };
17266        let held_sample = self.value_counts.current().map(|c| c.is_sample());
17267        if held_sample == Some(false) || (held_sample == Some(true) && !exact) {
17268            return;
17269        }
17270        if self
17271            .value_counts
17272            .computing
17273            .as_ref()
17274            .is_some_and(|c| c.column == column && (c.exact || !exact))
17275        {
17276            return;
17277        }
17278        self.stop_value_count();
17279        let Some(state) = self.data_table_state.as_ref() else {
17280            return;
17281        };
17282        let read = if exact {
17283            value_counts::Read::Exact
17284        } else {
17285            value_counts::Read::Quick {
17286                sample_rows: self.app_config.analysis.sample_rows,
17287                seed: self.analysis_modal.sample.seed,
17288                remote: state.is_remote_source(),
17289            }
17290        };
17291        let plan = value_counts::Plan {
17292            lf: state.analysis_lf(),
17293            column: column.clone(),
17294            read,
17295            known_total: state.num_rows_if_valid(),
17296            streaming: state.polars_streaming(),
17297        };
17298        let watch = crate::sampling::ReadWatch::default();
17299        self.value_counts.failed = None;
17300        self.value_counts.computing = Some(value_counts_modal::Computing {
17301            column,
17302            exact,
17303            watch: watch.clone(),
17304            file_starts: state.file_row_starts().map(Arc::new),
17305        });
17306        self.spawn_job(Job::ValueCounts, None, move |_| {
17307            plan.run(&watch)
17308                .map(|counts| Answer::ValueCounts(Box::new(counts)))
17309                .map_err(|e| crate::error_display::user_message_from_report(&e, None))
17310        });
17311    }
17312
17313    /// Stop the count in flight, if one is: its read stops at its next batch and its
17314    /// answer is dropped.
17315    fn stop_value_count(&mut self) {
17316        if let Some(computing) = self.value_counts.computing.take() {
17317            computing.watch.stop();
17318            self.jobs.cancel(|job| matches!(job, Job::ValueCounts));
17319        }
17320    }
17321
17322    /// Keys on the Value Counts screen.
17323    fn value_counts_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
17324        if !event.is_press() {
17325            return None;
17326        }
17327        let page = self.value_counts_page() as isize;
17328        // The histogram has no lines to move through or drill into.
17329        let listing = !self.value_counts.shows_histogram();
17330        match event.code {
17331            // A count still reading stops; with nothing to show for the column, Esc
17332            // goes on back to the table.
17333            KeyCode::Esc => {
17334                let counting = self.value_counts.counting();
17335                self.stop_value_count();
17336                if counting && self.value_counts.current().is_some() {
17337                    self.flash_note("Count stopped".to_string());
17338                } else {
17339                    self.input_mode = InputMode::Normal;
17340                }
17341            }
17342            KeyCode::Down | KeyCode::Char('j') if listing => self.value_counts.move_by(1),
17343            KeyCode::Up | KeyCode::Char('k') if listing => self.value_counts.move_by(-1),
17344            KeyCode::PageDown if listing => self.value_counts.move_by(page),
17345            KeyCode::PageUp if listing => self.value_counts.move_by(-page),
17346            KeyCode::Home if listing => self.value_counts.move_to_start(),
17347            KeyCode::End | KeyCode::Char('G') if listing => self.value_counts.move_to_end(),
17348            KeyCode::Left | KeyCode::Right | KeyCode::Char('h') | KeyCode::Char('l') => {
17349                let by = if matches!(event.code, KeyCode::Left | KeyCode::Char('h')) {
17350                    -1
17351                } else {
17352                    1
17353                };
17354                if self.value_counts.step(by) {
17355                    // The table follows, so Esc lands on the column last counted.
17356                    if let (Some(state), Some(column)) =
17357                        (self.data_table_state.as_mut(), self.value_counts.column())
17358                    {
17359                        state.set_current_column(column);
17360                    }
17361                    self.count_values(false);
17362                }
17363            }
17364            KeyCode::Char('s') if listing => self.value_counts.toggle_order(),
17365            KeyCode::Char('c') => self.value_counts.toggle_view(),
17366            KeyCode::Char('a') => {
17367                if self.value_counts.current().is_some_and(|c| c.is_sample()) {
17368                    self.count_values(true);
17369                }
17370            }
17371            KeyCode::Enter if listing => self.drill_into_counted_value(),
17372            KeyCode::Char('y') => self.copy_value_counts(),
17373            KeyCode::Char('e') => self.export_value_counts(),
17374            // The counts keep the rows they were read of; `t` counts the new ones too.
17375            KeyCode::Char('t') if self.follow_rows_waiting() => {
17376                self.stop_value_count();
17377                self.take_follow_rows(false);
17378                if let Some(state) = self.data_table_state.as_ref() {
17379                    let names = self.value_counts.columns.clone();
17380                    let at = self.value_counts.at;
17381                    self.value_counts.open(names, at, state.len_generation());
17382                }
17383                self.count_values(false);
17384            }
17385            _ => {}
17386        }
17387        None
17388    }
17389
17390    /// Lines a page of the Value Counts listing moves: as many as the last frame drew.
17391    fn value_counts_page(&self) -> usize {
17392        self.value_counts.page.max(1)
17393    }
17394
17395    /// Enter on Value Counts: the rows holding the value under the cursor, as a drill.
17396    fn drill_into_counted_value(&mut self) {
17397        let Some(kind) = self.value_counts.selected_kind() else {
17398            return;
17399        };
17400        let (Some(counts), Some(column)) = (
17401            self.value_counts.current().cloned(),
17402            self.value_counts.column().map(str::to_string),
17403        ) else {
17404            return;
17405        };
17406        let value = match kind {
17407            value_counts::LineKind::Value(at) => match counts.value(at) {
17408                Ok(value) => value,
17409                Err(_) => return,
17410            },
17411            value_counts::LineKind::Null => polars::prelude::AnyValue::Null,
17412            value_counts::LineKind::Other(_) => {
17413                self.flash_note("Other is many values: pick one to see its rows".to_string());
17414                return;
17415            }
17416        };
17417        let Some(state) = self.data_table_state.as_mut() else {
17418            return;
17419        };
17420        let nested = state.is_drilled_down();
17421        match state.deferred(|s| s.drill_into_value(&column, value)) {
17422            Ok(()) => {
17423                self.stop_value_count();
17424                // Inside a group already, Esc goes back past this view to the one
17425                // the group came from, so there are no counts to come back to.
17426                self.value_counts.drill_return = !nested;
17427                self.input_mode = InputMode::Normal;
17428                self.sync_sort_filter_modal();
17429                self.spawn_async_collect(Self::LOADING_BUFFER);
17430            }
17431            Err(e) => self.flash_note(format!(
17432                "Could not drill in: {}",
17433                crate::error_display::user_message_from_report(&e, None)
17434            )),
17435        }
17436    }
17437
17438    /// `y` on Value Counts: every value with its count and percentages, as TSV.
17439    fn copy_value_counts(&mut self) {
17440        let Some(counts) = self.value_counts.current().cloned() else {
17441            return;
17442        };
17443        let order = self.value_counts.order;
17444        let html = match self.copy_destination() {
17445            Ok(destination) => destination.accepts().html,
17446            Err(e) => {
17447                self.error_modal.show(e);
17448                return;
17449            }
17450        };
17451        self.spawn_job(Job::Copy, Some("Copying..."), move |_| {
17452            let table = counts
17453                .table(order)
17454                .map_err(|e| format!("Copy failed: {e}"))?;
17455            let payload =
17456                crate::clipboard::tabular_payload(&table, clipboard::CopyFormat::Tsv, true, html)
17457                    .map_err(|e| format!("Copy failed: {e}"))?;
17458            Ok(Answer::Copied {
17459                payload,
17460                message: format!(
17461                    "Copied {} values as TSV",
17462                    copy_modal::thousands(table.height())
17463                ),
17464            })
17465        });
17466    }
17467
17468    /// `e` on Value Counts: the export dialog, writing the counts.
17469    fn export_value_counts(&mut self) {
17470        let Some(counts) = self.value_counts.current() else {
17471            return;
17472        };
17473        let table = match counts.table(self.value_counts.order) {
17474            Ok(table) => table,
17475            Err(e) => {
17476                self.error_modal
17477                    .show(format!("Cannot export the counts: {e}"));
17478                return;
17479            }
17480        };
17481        self.export_modal.open(
17482            self.original_file_format,
17483            self.history_limit,
17484            &self.theme,
17485            self.original_file_delimiter,
17486        );
17487        let stem = self.dataset_stem();
17488        self.export_modal.suggest_path(&format!("{stem}-counts"));
17489        self.export_modal.offer_source_file = false;
17490        self.export_modal.nested_columns = false;
17491        self.export_modal.avro_renames = table
17492            .columns()
17493            .iter()
17494            .any(|c| crate::avro_types::renames(c.name(), c.dtype()));
17495        self.export_counts = Some(table);
17496        self.input_mode = InputMode::Export;
17497    }
17498
17499    /// `g` at the table: pick a shown column by name, bring it on screen and put the
17500    /// column cursor on it. Starts on the cursor's column, so ↑↓ move from there.
17501    fn open_go_to_column(&mut self) {
17502        let Some(state) = self.data_table_state.as_ref() else {
17503            return;
17504        };
17505        let names = state.get_column_order().to_vec();
17506        if names.is_empty() {
17507            return;
17508        }
17509        let at = state
17510            .current_column()
17511            .and_then(|current| names.iter().position(|n| n == current))
17512            .unwrap_or(0);
17513        self.go_to_column = crate::widgets::ui::PickerState::new(names);
17514        self.go_to_column.select_original(at);
17515        self.input_mode = InputMode::GoToColumn;
17516    }
17517
17518    /// The column picker owns the keys: type to narrow, ↑↓ move, Enter goes, Esc
17519    /// closes without moving.
17520    fn go_to_column_key(&mut self, event: &KeyEvent) {
17521        match event.code {
17522            KeyCode::Esc => self.input_mode = InputMode::Normal,
17523            KeyCode::Enter => {
17524                let Some(index) = self.go_to_column.selected_original() else {
17525                    // Nothing matches; the picker says so and stays.
17526                    return;
17527                };
17528                // By name: the order may have changed under the picker since it opened.
17529                let name = self.go_to_column.items()[index].clone();
17530                if let Some(state) = self.data_table_state.as_mut() {
17531                    state.go_to_column(&name);
17532                }
17533                self.input_mode = InputMode::Normal;
17534            }
17535            KeyCode::Up => self.go_to_column.move_up(),
17536            KeyCode::Down => self.go_to_column.move_down(),
17537            KeyCode::Backspace => self.go_to_column.backspace(),
17538            KeyCode::Char(c) => self.go_to_column.filter_key(c, event.modifiers),
17539            _ => {}
17540        }
17541    }
17542
17543    /// `b` at a table read through a format spec: the specs that could read it, the
17544    /// ones that matched first, to read it again with another.
17545    fn open_format_picker(&mut self) {
17546        let Some(read) = self
17547            .data_table_state
17548            .as_ref()
17549            .and_then(|s| s.format_read())
17550            .cloned()
17551        else {
17552            // Only a file read through a spec has a format to pick.
17553            return;
17554        };
17555        let mut names = vec![read.spec.name.clone()];
17556        names.extend(read.also.iter().cloned());
17557        for found in &self.formats.specs {
17558            if found.spec.layout == read.spec.layout
17559                && !found.spec.is_delimited()
17560                && !names.contains(&found.spec.name)
17561            {
17562                names.push(found.spec.name.clone());
17563            }
17564        }
17565        self.format_picker = crate::widgets::ui::PickerState::new(names);
17566        self.input_mode = InputMode::PickFormat;
17567    }
17568
17569    /// The format picker owns the keys: type to narrow, ↑↓ move, Enter reads the file
17570    /// again with the spec chosen, Esc closes.
17571    fn format_picker_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
17572        match event.code {
17573            KeyCode::Esc => self.input_mode = InputMode::Normal,
17574            KeyCode::Enter => {
17575                let index = self.format_picker.selected_original()?;
17576                let name = self.format_picker.items()[index].clone();
17577                self.input_mode = InputMode::Normal;
17578                let current = self
17579                    .data_table_state
17580                    .as_ref()
17581                    .and_then(|s| s.format_read())
17582                    .map(|read| read.spec.name.clone());
17583                if current.as_deref() == Some(name.as_str()) {
17584                    return None;
17585                }
17586                let (paths, options) = self.opened.clone()?;
17587                let options = OpenOptions {
17588                    spec_name: Some(name),
17589                    spec_file: None,
17590                    spec_fetched: None,
17591                    table: None,
17592                    format_read: None,
17593                    sqlite: None,
17594                    format: None,
17595                    ..options
17596                };
17597                self.set_loading_phase("Scanning input", 10);
17598                self.name_what_is_loading(paths[0].clone());
17599                return Some(AppEvent::Open(paths, options));
17600            }
17601            KeyCode::Up => self.format_picker.move_up(),
17602            KeyCode::Down => self.format_picker.move_down(),
17603            KeyCode::Backspace => self.format_picker.backspace(),
17604            KeyCode::Char(c) => self.format_picker.filter_key(c, event.modifiers),
17605            _ => {}
17606        }
17607        None
17608    }
17609
17610    /// The tables of the source on screen, from what its open holds; `None` for a
17611    /// source of one.
17612    pub fn sibling_tables(&self) -> Option<table_switch::Tables> {
17613        let state = self.data_table_state.as_ref()?;
17614        let (paths, options) = self.opened.as_ref()?;
17615        table_switch::of(state, paths, options)
17616    }
17617
17618    /// Whether the source on screen has another table for `T` to open.
17619    pub fn offers_other_tables(&self) -> bool {
17620        match (self.data_table_state.as_ref(), self.opened.as_ref()) {
17621            (Some(state), Some((paths, options))) => table_switch::several(state, paths, options),
17622            _ => false,
17623        }
17624    }
17625
17626    /// `T` at the table: the source's tables, the one on screen marked, to open
17627    /// another. A source of one says so.
17628    fn open_table_picker(&mut self) {
17629        let Some(tables) = self.sibling_tables().filter(table_switch::Tables::several) else {
17630            self.flash_note("Only one table here".to_string());
17631            return;
17632        };
17633        let labels = tables.tables.iter().map(|t| t.label.clone()).collect();
17634        self.table_picker = crate::widgets::ui::PickerState::new(labels);
17635        if let Some(at) = tables.current {
17636            self.table_picker.select_original(at);
17637        }
17638        self.table_choices = Some(tables);
17639        self.input_mode = InputMode::PickTable;
17640    }
17641
17642    /// The table picker owns the keys: type to narrow, ↑↓ move, Enter opens the table
17643    /// chosen, Esc closes.
17644    fn table_picker_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
17645        match event.code {
17646            KeyCode::Esc => {
17647                self.input_mode = InputMode::Normal;
17648                self.table_choices = None;
17649            }
17650            KeyCode::Enter => {
17651                let index = self.table_picker.selected_original()?;
17652                let tables = self.table_choices.take()?;
17653                self.input_mode = InputMode::Normal;
17654                if tables.current == Some(index) {
17655                    return None;
17656                }
17657                let table = tables.tables.get(index)?.table.clone();
17658                return self.switch_table(table);
17659            }
17660            KeyCode::Up => self.table_picker.move_up(),
17661            KeyCode::Down => self.table_picker.move_down(),
17662            KeyCode::Backspace => self.table_picker.backspace(),
17663            KeyCode::Char(c) => self.table_picker.filter_key(c, event.modifiers),
17664            _ => {}
17665        }
17666        None
17667    }
17668
17669    /// Open `table` of the file on screen in its place (`None`: the whole file), as
17670    /// `--table` or home's row for it would: the query, filters and sort go with the
17671    /// table they were on, recents record it, and a view for it applies.
17672    pub(crate) fn switch_table(&mut self, table: Option<String>) -> Option<AppEvent> {
17673        let (paths, options) = self.opened.clone()?;
17674        let shown = match &table {
17675            Some(name) => crate::members::place(&paths[0], name),
17676            None => paths[0].clone(),
17677        };
17678        let options = OpenOptions {
17679            table,
17680            // `--view` was for the first open; a view for this table applies as on
17681            // any open.
17682            view: None,
17683            prepared: None,
17684            ..options
17685        };
17686        self.set_loading_phase("Scanning input", 10);
17687        self.name_what_is_loading(shown);
17688        Some(AppEvent::Open(paths, options))
17689    }
17690
17691    fn close_inspector(&mut self) {
17692        self.inspector_modal.close();
17693        self.input_mode = InputMode::Normal;
17694    }
17695
17696    /// Enter on a row of a grouped view: its group's rows, read off this thread
17697    /// when the buffer does not hold the row.
17698    fn drill_selected_row(&mut self) {
17699        let Some(state) = self.data_table_state.as_ref() else {
17700            return;
17701        };
17702        // An empty result has no row selected, and says so like any other.
17703        let drill = state
17704            .table_state
17705            .selected()
17706            .map(|selected| state.start_row() + selected)
17707            .and_then(|index| Some((index, state.drill_row(index)?)));
17708        match drill {
17709            None => self.flash_note("No group to drill down into".to_string()),
17710            Some((group_index, DrillRow::Buffered(row))) => self.drill_into(group_index, &row),
17711            Some((group_index, DrillRow::Read(lf))) => {
17712                let streaming = state.polars_streaming();
17713                self.spawn_job(Job::DrillRow, Some(Self::READING_GROUP), move |_| {
17714                    let row = crate::statistics::collect_lazy(*lf, streaming)
17715                        .map_err(|e| crate::error_display::user_message_from_polars(&e))?;
17716                    Ok(Answer::DrillRow { group_index, row })
17717                });
17718            }
17719        }
17720    }
17721
17722    /// The inspector's list as the row shown has it: Filled, Compare and the find
17723    /// text depend on the row's values, which a key may have moved.
17724    fn refresh_inspector_list(&mut self) {
17725        if let Some(state) = self.data_table_state.as_ref() {
17726            let visible = crate::widgets::inspector::visible_fields(&self.inspector_modal, state);
17727            self.inspector_modal.set_visible(visible);
17728        }
17729    }
17730
17731    /// The pane for the focused value: as last drawn while that is still the
17732    /// focused value, else built for the key (without the table's preview, which
17733    /// only a frame knows).
17734    fn inspector_pane(&self) -> Option<crate::widgets::inspector::Pane> {
17735        let modal = &self.inspector_modal;
17736        if modal.drill.is_some() {
17737            return modal.pane.as_ref().map(|(_, pane)| pane.clone());
17738        }
17739        let state = self.data_table_state.as_ref()?;
17740        let row = state.inspect_row()?;
17741        let field = modal.focused()?;
17742        if let Some(pane) = modal.pane_for(row.frame, row.row, &field.name) {
17743            return Some(pane.clone());
17744        }
17745        let shown = crate::widgets::inspector::shown(field, &row, modal.read.as_ref(), state);
17746        Some(crate::widgets::inspector::pane(
17747            &field.dtype,
17748            &shown,
17749            &crate::widgets::inspector::PaneAsk {
17750                choice: modal.view,
17751                width: modal
17752                    .pane
17753                    .as_ref()
17754                    .map_or(80, |(key, _)| key.width as usize),
17755                table: None,
17756                indented: crate::widgets::inspector::Indented::None,
17757                not_json: modal.known_not_json(row.frame, row.row, &field.name),
17758                unpacked: modal.unpacked(&(row.frame, row.row, field.name.clone())),
17759                read_key: "Enter",
17760            },
17761        ))
17762    }
17763
17764    /// The inspector's keys. Moving between rows moves the table's cursor, so the
17765    /// table is where the inspector left it on close.
17766    fn inspector_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
17767        if !event.is_press() {
17768            return None;
17769        }
17770        let modal = &mut self.inspector_modal;
17771        if modal.finding {
17772            match event.code {
17773                KeyCode::Esc => modal.clear_find(),
17774                KeyCode::Enter | KeyCode::Tab | KeyCode::Down => modal.finding = false,
17775                KeyCode::Up => {
17776                    modal.finding = false;
17777                    self.refresh_inspector_list();
17778                    self.inspector_modal.prev_field();
17779                    return None;
17780                }
17781                KeyCode::Backspace => modal.find_backspace(),
17782                KeyCode::Char(c) => modal.find_key(c, event.modifiers),
17783                _ => {}
17784            }
17785            // The focus follows the narrowing now, not at the next frame: a key
17786            // replayed before it acts on the field the find left focused.
17787            self.refresh_inspector_list();
17788            return None;
17789        }
17790        if modal.value_find.as_ref().is_some_and(|f| f.editing) {
17791            self.value_find_key(event);
17792            return None;
17793        }
17794        if event
17795            .modifiers
17796            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
17797        {
17798            return None;
17799        }
17800        if modal.focus == inspector_modal::Focus::Value {
17801            return self.inspector_value_key(event);
17802        }
17803        if modal.drill.is_some() {
17804            return self.drill_key(event);
17805        }
17806        self.refresh_inspector_list();
17807        let modal = &mut self.inspector_modal;
17808        match event.code {
17809            // Esc backs out one level at a time: the find, then Compare, then the
17810            // inspector.
17811            KeyCode::Esc if !modal.filter.is_empty() => modal.clear_find(),
17812            KeyCode::Esc if modal.compare => {
17813                modal.compare = false;
17814                modal.filled_only = false;
17815            }
17816            KeyCode::Esc | KeyCode::Char(' ') => self.close_inspector(),
17817            KeyCode::Down | KeyCode::Char('j') => modal.next_field(),
17818            KeyCode::Up | KeyCode::Char('k') => modal.prev_field(),
17819            KeyCode::Home => modal.first_field(),
17820            KeyCode::End => modal.last_field(),
17821            KeyCode::PageDown => modal.page_fields(1),
17822            KeyCode::PageUp => modal.page_fields(-1),
17823            // Two panes: Tab and Shift+Tab both cross to the value.
17824            KeyCode::Tab | KeyCode::BackTab => {
17825                if modal.focused().is_some() {
17826                    modal.focus = inspector_modal::Focus::Value;
17827                }
17828            }
17829            KeyCode::Char('/') => modal.finding = true,
17830            KeyCode::Char('e') => self.inspector_view(),
17831            KeyCode::Char('w') => self.inspector_wrap(),
17832            KeyCode::Char('f') => {
17833                modal.filled_only = !modal.filled_only;
17834                modal.list_offset = 0;
17835            }
17836            KeyCode::Char('s') => {
17837                modal.order = modal.order.next();
17838                modal.list_offset = 0;
17839            }
17840            KeyCode::Char('c') => {
17841                modal.compare = !modal.compare;
17842                if !modal.compare {
17843                    modal.filled_only = false;
17844                }
17845            }
17846            KeyCode::Char('m') => self.toggle_inspector_pin(),
17847            KeyCode::Right | KeyCode::Char('l') => return self.step_row(1),
17848            KeyCode::Left | KeyCode::Char('h') => return self.step_row(-1),
17849            KeyCode::Char('y') => self.copy_inspected_field(),
17850            KeyCode::Char('Y') => self.copy_inspected_row(),
17851            KeyCode::Char('o') => self.open_inspected_value(),
17852            KeyCode::Char('r') => self.read_focused_field(),
17853            KeyCode::Enter => return self.inspector_enter(),
17854            _ => {}
17855        }
17856        None
17857    }
17858
17859    /// The keys with the focus in the value: scroll it, search it, change its view.
17860    fn inspector_value_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
17861        let Some(pane) = self.inspector_pane() else {
17862            self.inspector_modal.focus = inspector_modal::Focus::List;
17863            return None;
17864        };
17865        let modal = &mut self.inspector_modal;
17866        let h = modal.page.max(1);
17867        let content = &pane.content;
17868        let page = h.saturating_sub(1).max(1) as isize;
17869        match event.code {
17870            KeyCode::Esc
17871                if modal
17872                    .value_find
17873                    .as_ref()
17874                    .is_some_and(|f| !f.text.is_empty()) =>
17875            {
17876                modal.value_find = None;
17877            }
17878            KeyCode::Esc | KeyCode::Tab | KeyCode::BackTab => {
17879                modal.focus = inspector_modal::Focus::List;
17880            }
17881            KeyCode::Char(' ') => self.close_inspector(),
17882            KeyCode::Down | KeyCode::Char('j') => modal.reader.scroll(content, h, 1),
17883            KeyCode::Up | KeyCode::Char('k') => modal.reader.scroll(content, h, -1),
17884            KeyCode::PageDown => modal.reader.scroll(content, h, page),
17885            KeyCode::PageUp => modal.reader.scroll(content, h, -page),
17886            KeyCode::Home => modal.reader.home(),
17887            KeyCode::End => modal.reader.end(content, h),
17888            KeyCode::Char('/') => {
17889                modal.value_find = Some(inspector_modal::ValueFind {
17890                    editing: true,
17891                    pane: pane.id,
17892                    ..Default::default()
17893                });
17894            }
17895            KeyCode::Char('n') => self.next_value_hit(1),
17896            KeyCode::Char('N') => self.next_value_hit(-1),
17897            KeyCode::Char('e') => self.inspector_view(),
17898            KeyCode::Char('w') => self.inspector_wrap(),
17899            KeyCode::Char('y') => {
17900                if modal.drill.is_some() {
17901                    self.copy_drilled_item();
17902                } else {
17903                    self.copy_inspected_field();
17904                }
17905            }
17906            KeyCode::Char('o') if modal.drill.is_none() => self.open_inspected_value(),
17907            KeyCode::Right | KeyCode::Char('l') if modal.drill.is_none() => {
17908                return self.step_row(1);
17909            }
17910            KeyCode::Left | KeyCode::Char('h') if modal.drill.is_none() => {
17911                return self.step_row(-1);
17912            }
17913            _ => {}
17914        }
17915        None
17916    }
17917
17918    /// A key typed into the value's find line. Enter finds every place and goes
17919    /// to the first at or after the pane's top.
17920    fn value_find_key(&mut self, event: &KeyEvent) {
17921        let pane = self.inspector_pane();
17922        let modal = &mut self.inspector_modal;
17923        let Some(find) = modal.value_find.as_mut() else {
17924            return;
17925        };
17926        match event.code {
17927            KeyCode::Esc => modal.value_find = None,
17928            KeyCode::Enter => {
17929                find.editing = false;
17930                let Some(pane) = pane else {
17931                    return;
17932                };
17933                find.hits = inspector_reader::find_hits(&pane.content, &find.text);
17934                find.pane = pane.id;
17935                let h = modal.page.max(1);
17936                let from = modal.reader.window(&pane.content, h).from;
17937                let at = find.hits.partition_point(|&p| p < from);
17938                find.current = (!find.hits.is_empty()).then(|| at % find.hits.len());
17939                if let Some(at) = find.current {
17940                    let pos = find.hits[at];
17941                    modal.reader.jump(&pane.content, h, pos);
17942                }
17943            }
17944            KeyCode::Backspace => {
17945                find.text.pop();
17946            }
17947            KeyCode::Char(c) => inspector_modal::edit_find(&mut find.text, c, event.modifiers),
17948            _ => {}
17949        }
17950    }
17951
17952    /// `n` and `N` in the value: the next or the last place found, round the ends.
17953    fn next_value_hit(&mut self, step: isize) {
17954        let Some(pane) = self.inspector_pane() else {
17955            return;
17956        };
17957        let modal = &mut self.inspector_modal;
17958        let Some(find) = modal.value_find.as_mut() else {
17959            return;
17960        };
17961        if find.pane != pane.id && !find.text.is_empty() {
17962            find.hits = inspector_reader::find_hits(&pane.content, &find.text);
17963            find.pane = pane.id;
17964            find.current = None;
17965        }
17966        let n = find.hits.len();
17967        if n == 0 {
17968            return;
17969        }
17970        let at = match find.current {
17971            Some(at) => (at as isize + step).rem_euclid(n as isize) as usize,
17972            None if step > 0 => 0,
17973            None => n - 1,
17974        };
17975        find.current = Some(at);
17976        let pos = find.hits[at];
17977        let h = modal.page.max(1);
17978        modal.reader.jump(&pane.content, h, pos);
17979    }
17980
17981    /// The inspector's keys inside a level drilled into: the same moves as at the
17982    /// row, but `→` and Enter open the focused item and `←` and Esc step back up.
17983    fn drill_key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
17984        let modal = &mut self.inspector_modal;
17985        match event.code {
17986            KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => {
17987                modal.drill_out();
17988            }
17989            KeyCode::Char(' ') => self.close_inspector(),
17990            KeyCode::Down | KeyCode::Char('j') => modal.next_field(),
17991            KeyCode::Up | KeyCode::Char('k') => modal.prev_field(),
17992            KeyCode::Home => modal.first_field(),
17993            KeyCode::End => modal.last_field(),
17994            KeyCode::PageDown => modal.page_fields(1),
17995            KeyCode::PageUp => modal.page_fields(-1),
17996            // A level with nothing in it has no value to cross to.
17997            KeyCode::Tab | KeyCode::BackTab
17998                if modal
17999                    .drill
18000                    .as_ref()
18001                    .is_some_and(|drill| drill.level().focused().is_some()) =>
18002            {
18003                modal.focus = inspector_modal::Focus::Value;
18004            }
18005            KeyCode::Char('e') => self.inspector_view(),
18006            KeyCode::Char('w') => self.inspector_wrap(),
18007            KeyCode::Char('y') => self.copy_drilled_item(),
18008            KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
18009                let drill = modal.drill.as_ref()?;
18010                let (frame, row) = (drill.frame, drill.row);
18011                let (label, node) = drill.level().focused()?;
18012                let path = drill.item_key(&label);
18013                if node.opens() && !modal.known_not_json(frame, row, &path) {
18014                    self.inspector_open(frame, row, label, path, node);
18015                }
18016            }
18017            _ => {}
18018        }
18019        None
18020    }
18021
18022    /// `e` in the inspector: the focused value's next view, where it has more than
18023    /// one. A number never has, so it never changes how a text field is then shown.
18024    fn inspector_view(&mut self) {
18025        if let Some(view) = self.inspector_pane().and_then(|pane| pane.next_view()) {
18026            self.inspector_modal.choose_view(view);
18027        }
18028    }
18029
18030    /// `w`: word wrap or hard wrap, for every value until it is pressed again.
18031    fn inspector_wrap(&mut self) {
18032        let modal = &mut self.inspector_modal;
18033        modal.wrap = match modal.wrap {
18034            inspector_reader::Wrap::Word => inspector_reader::Wrap::Hard,
18035            inspector_reader::Wrap::Hard => inspector_reader::Wrap::Word,
18036        };
18037    }
18038
18039    /// `m`: pin this row for Compare, or let the pin go when it is this row.
18040    fn toggle_inspector_pin(&mut self) {
18041        let Some(row) = self.data_table_state.as_ref().and_then(|s| s.inspect_row()) else {
18042            return;
18043        };
18044        let modal = &mut self.inspector_modal;
18045        let here = modal
18046            .pinned
18047            .as_ref()
18048            .is_some_and(|p| (p.frame, p.row) == (row.frame, row.row));
18049        if here {
18050            modal.pinned = None;
18051            self.flash_note("Unpinned".to_string());
18052        } else {
18053            let n = row.display_row;
18054            modal.pinned = Some(row);
18055            modal.compare = true;
18056            self.flash_note(format!(
18057                "Pinned row {}; Compare shows it",
18058                copy_modal::thousands(n)
18059            ));
18060        }
18061    }
18062
18063    /// Enter in the inspector: on a group's row, its rows, as at the table; else
18064    /// open a nested value, or read the row's fields the buffer does not hold.
18065    fn inspector_enter(&mut self) -> Option<AppEvent> {
18066        let state = self.data_table_state.as_ref()?;
18067        if state.can_drill_down() {
18068            self.close_inspector();
18069            self.drill_selected_row();
18070            return None;
18071        }
18072        let row = state.inspect_row()?;
18073        let field = self.inspector_modal.focused().cloned()?;
18074        let shown = crate::widgets::inspector::shown(
18075            &field,
18076            &row,
18077            self.inspector_modal.read.as_ref(),
18078            state,
18079        );
18080        use crate::widgets::inspector::Shown;
18081        match shown {
18082            // A failed read is asked again: the pane said why, and Enter is the retry.
18083            Shown::Unread | Shown::Failed(_) => self.read_focused_field(),
18084            Shown::Value(ref v)
18085                if crate::widgets::inspector::value_opens(v)
18086                    && !self
18087                        .inspector_modal
18088                        .known_not_json(row.frame, row.row, &field.name) =>
18089            {
18090                let column = if field.buffered() {
18091                    row.values.column(&field.name).ok()
18092                } else {
18093                    self.inspector_modal
18094                        .read_values(row.frame, row.row)
18095                        .and_then(|values| values.column(&field.name).ok())
18096                };
18097                if let Some(column) = column {
18098                    let node =
18099                        inspector_drill::Node::Native(column.as_materialized_series().clone());
18100                    let path = inspector_drill::path_key([field.name.as_str()]);
18101                    self.inspector_open(row.frame, row.row, field.name.clone(), path, node);
18102                }
18103            }
18104            _ => {}
18105        }
18106        None
18107    }
18108
18109    /// Read the focused row's hidden and binary fields, waited on; from then on the
18110    /// rows moved to are read too while the focus stays on this field.
18111    fn read_focused_field(&mut self) {
18112        let Some(state) = self.data_table_state.as_ref() else {
18113            return;
18114        };
18115        let Some(row) = state.inspect_row() else {
18116            return;
18117        };
18118        let Some(field) = self.inspector_modal.focused().cloned() else {
18119            return;
18120        };
18121        let shown = crate::widgets::inspector::shown(
18122            &field,
18123            &row,
18124            self.inspector_modal.read.as_ref(),
18125            state,
18126        );
18127        if matches!(
18128            shown,
18129            crate::widgets::inspector::Shown::Unread | crate::widgets::inspector::Shown::Failed(_)
18130        ) {
18131            self.inspector_modal.follow = Some(field.name.clone());
18132            self.read_inspected_fields(&row, true);
18133        }
18134    }
18135
18136    /// What the inspector needs after a pass: the row moved to read while a read
18137    /// follows the rows, long JSON indented for its JSON view, and compressed
18138    /// bytes decompressed for their Text view. None holds the keys: moving on
18139    /// drops what is no longer wanted.
18140    fn inspector_needs(&mut self) {
18141        if self.input_mode != InputMode::Inspect || !self.inspector_modal.active {
18142            return;
18143        }
18144        let Some(state) = self.data_table_state.as_ref() else {
18145            return;
18146        };
18147        let Some(row) = state.inspect_row() else {
18148            return;
18149        };
18150        let modal = &self.inspector_modal;
18151        if modal.drill.is_some() {
18152            return;
18153        }
18154        let Some(field) = modal.focused().cloned() else {
18155            return;
18156        };
18157        let read_here = modal
18158            .read
18159            .as_ref()
18160            .is_some_and(|r| r.key() == (row.frame, row.row));
18161        if modal.follow.as_deref() == Some(field.name.as_str()) && !field.buffered() && !read_here {
18162            self.read_inspected_fields(&row, false);
18163            return;
18164        }
18165        // Long JSON text asked for the JSON view and not yet indented, or
18166        // compressed bytes asked for the Text view and not yet decompressed.
18167        let pane = modal.pane_for(row.frame, row.row, &field.name);
18168        let place = (row.frame, row.row, field.name.clone());
18169        let indent = pane.is_some_and(|pane| pane.indent)
18170            && !modal.pretty.as_ref().is_some_and(|p| *p.place() == place);
18171        let unpack = pane.is_some_and(|pane| pane.unpack)
18172            && !modal.unpack.as_ref().is_some_and(|u| *u.place() == place);
18173        if !indent && !unpack {
18174            return;
18175        }
18176        let column = if field.buffered() {
18177            row.values.column(&field.name).ok().cloned()
18178        } else {
18179            modal
18180                .read_values(row.frame, row.row)
18181                .and_then(|values| values.column(&field.name).ok().cloned())
18182        };
18183        let Some(column) = column else {
18184            return;
18185        };
18186        let modal = &mut self.inspector_modal;
18187        if unpack {
18188            modal.unpack_token += 1;
18189            let token = modal.unpack_token;
18190            modal.unpack = Some(inspector_modal::Unpack::Pending { token, place });
18191            self.spawn_job(Job::InspectUnpack { token }, None, move |_| {
18192                let value = column.get(0).map_err(|e| e.to_string())?;
18193                let bytes = match &value {
18194                    polars::prelude::AnyValue::Binary(b) => *b,
18195                    polars::prelude::AnyValue::BinaryOwned(b) => b.as_slice(),
18196                    _ => return Err("not bytes".to_string()),
18197                };
18198                inspector_bytes::decode_text(bytes, inspector_bytes::sniff(bytes))
18199                    .map(Answer::Unpacked)
18200                    .ok_or_else(|| "not text".to_string())
18201            });
18202            return;
18203        }
18204        modal.pretty_token += 1;
18205        let token = modal.pretty_token;
18206        modal.pretty = Some(inspector_modal::Pretty::Pending { token, place });
18207        self.spawn_job(Job::InspectPretty { token }, None, move |_| {
18208            let value = column.get(0).map_err(|e| e.to_string())?;
18209            let text = match &value {
18210                polars::prelude::AnyValue::String(s) => *s,
18211                polars::prelude::AnyValue::StringOwned(s) => s.as_str(),
18212                _ => return Err("not text".to_string()),
18213            };
18214            let json = inspector_drill::parse_json(text)?;
18215            let (pretty, _) = inspector_drill::json_text(&json, true, usize::MAX);
18216            Ok(Answer::Indented(std::sync::Arc::from(pretty)))
18217        });
18218    }
18219
18220    /// Under `theme.mode = "auto"`, switch to the theme for the terminal's background
18221    /// (`theme.dark` or `theme.light`), keeping the configured `theme.colors` over it as at startup. An
18222    /// explicit mode ignores the terminal.
18223    pub fn follow_terminal_background(&mut self, mode: ThemeMode) {
18224        let theme = &self.app_config.theme;
18225        if !theme.follow || theme.mode == Some(mode) {
18226            return;
18227        }
18228        let mut next = theme.clone();
18229        let built = theme.palette_for(mode).and_then(|colors| {
18230            next.colors = colors;
18231            next.mode = Some(mode);
18232            Theme::from_config(&next)
18233        });
18234        match built {
18235            Ok(built) => {
18236                self.theme = built;
18237                self.chart_modal.series_cap = Some(self.theme.series_colors().len());
18238                self.app_config.theme = next;
18239                // The prompts live as long as the app and keep the colors they were
18240                // given; a dialog's fields take the theme each time it opens.
18241                for input in [
18242                    &mut self.query_input,
18243                    &mut self.sql_input,
18244                    &mut self.find.input,
18245                ] {
18246                    *input = std::mem::take(input).with_theme(&self.theme);
18247                }
18248            }
18249            // The configured colors parsed at startup, so this is not expected; the
18250            // palette in use stays.
18251            Err(e) => log::warn!("cannot switch to the {mode:?} palette: {e}"),
18252        }
18253    }
18254
18255    /// The terminal said what its background is: follow it under `auto`, and remember
18256    /// it for the next start's first frame.
18257    fn terminal_answered(&mut self, mode: ThemeMode) {
18258        if self.app_config.theme.follow {
18259            self.cache
18260                .remember_terminal_mode(&terminal_color::terminal_key(), mode);
18261        }
18262        self.follow_terminal_background(mode);
18263    }
18264
18265    /// Settle the palette of the first frame under `auto`, without waiting for the
18266    /// terminal: its answer when `answered` has it, else what this terminal answered
18267    /// last time. An answer that comes later switches palettes if it differs.
18268    pub fn settle_first_palette(&mut self, answered: Option<ThemeMode>) {
18269        if let Some(mode) = answered {
18270            self.terminal_answered(mode);
18271        } else if self.app_config.theme.follow
18272            && let Some(mode) = self.cache.terminal_mode(&terminal_color::terminal_key())
18273        {
18274            self.follow_terminal_background(mode);
18275        }
18276    }
18277
18278    /// Whether the run loop should ask the terminal for its background, once. Asked by
18279    /// [`AppEvent::TerminalFocused`] under `auto`.
18280    pub fn take_background_query(&mut self) -> bool {
18281        std::mem::take(&mut self.background_query)
18282    }
18283
18284    /// The colors the next frame is drawn with.
18285    pub fn theme(&self) -> &Theme {
18286        &self.theme
18287    }
18288
18289    /// Whether the palette follows the terminal (`theme.mode = "auto"`).
18290    pub fn follows_terminal(&self) -> bool {
18291        self.app_config.theme.follow
18292    }
18293
18294    /// The value the inspector wrote for another program, for the run loop.
18295    pub fn take_external_open(&mut self) -> Option<external_open::ExternalOpen> {
18296        self.external_open.take()
18297    }
18298
18299    /// Whether the session reports the mouse, to take it again after a program
18300    /// had the terminal.
18301    pub fn mouse_enabled(&self) -> bool {
18302        self.app_config.display.mouse
18303    }
18304
18305    /// The run loop opened `open`: a program that waited is done with its file;
18306    /// a failure is said on the bar.
18307    pub fn external_opened(&mut self, open: &external_open::ExternalOpen, failed: Option<String>) {
18308        let program = external_open::program_for(open.document, |name| std::env::var(name).ok());
18309        if matches!(program, external_open::Program::Wait(_)) {
18310            let _ = std::fs::remove_file(&open.path);
18311        }
18312        match failed {
18313            Some(e) => self.flash_note(format!("Could not open the value: {e}")),
18314            None if matches!(program, external_open::Program::Opener(_)) => {
18315                self.flash_note("Opened in the system viewer".to_string())
18316            }
18317            None => {}
18318        }
18319    }
18320
18321    /// `y` in the inspector: the focused value as its view shows it — the stored
18322    /// value exact, indented JSON in the JSON view, the text bytes hold in their
18323    /// Text view, bytes otherwise as base64 — through the same clipboard path as
18324    /// the copy dialog. One over a capped destination's limit is refused
18325    /// unformatted; a large one is written off this thread.
18326    fn copy_inspected_field(&mut self) {
18327        use copy_modal::thousands;
18328        let Some(state) = self.data_table_state.as_ref() else {
18329            return;
18330        };
18331        let (Some(row), Some(field)) = (state.inspect_row(), self.inspector_modal.focused()) else {
18332            return;
18333        };
18334        let field = field.clone();
18335        let column = if field.buffered() {
18336            row.values.column(&field.name).ok().cloned()
18337        } else {
18338            self.inspector_modal
18339                .read_values(row.frame, row.row)
18340                .and_then(|values| values.column(&field.name).ok().cloned())
18341        };
18342        let Some(column) = column else {
18343            self.flash_note(format!("{} is not read yet; Enter reads it", field.name));
18344            return;
18345        };
18346        let message = format!(
18347            "Copied {} of row {}",
18348            field.name,
18349            thousands(row.display_row)
18350        );
18351        use crate::widgets::inspector::CopyAs;
18352        match self.inspector_pane().map(|pane| pane.copy) {
18353            Some(CopyAs::Text(text)) => self.copy_string(text.to_string(), message),
18354            Some(CopyAs::Escaped) => {
18355                let text = column
18356                    .get(0)
18357                    .map(|v| crate::exact::escaped(&crate::exact::value_text(&v)))
18358                    .unwrap_or_default();
18359                self.copy_string(text, message);
18360            }
18361            _ => self.copy_value(column, message),
18362        }
18363    }
18364
18365    /// Copy `text`, refused when it is over a capped destination's limit.
18366    fn copy_string(&mut self, text: String, message: String) {
18367        let limit = match self.copy_destination() {
18368            Ok(destination) => destination.accepts().base64_limit,
18369            Err(e) => {
18370                self.error_modal.show(e);
18371                return;
18372            }
18373        };
18374        if let Some(limit) = limit
18375            && text.len() > limit / 4 * 3
18376        {
18377            self.error_modal
18378                .show(clipboard::over_osc52_limit(None, limit));
18379            return;
18380        }
18381        self.finish_copy(clipboard::Payload::text(text), message);
18382    }
18383
18384    /// `Y` in the inspector: the whole row as one JSON object, exact, without
18385    /// leaving. Fields not read are left out, and the flash counts them.
18386    fn copy_inspected_row(&mut self) {
18387        use copy_modal::thousands;
18388        let Some(state) = self.data_table_state.as_ref() else {
18389            return;
18390        };
18391        let Some(row) = state.inspect_row() else {
18392            return;
18393        };
18394        let fields = self.inspector_modal.fields.clone();
18395        let read = self.inspector_modal.read.clone();
18396        let display = row.display_row;
18397        let size = row.values.estimated_size()
18398            + self
18399                .inspector_modal
18400                .read_values(row.frame, row.row)
18401                .map_or(0, |v| v.estimated_size());
18402        let build = move || {
18403            let (json, kept, unread) =
18404                crate::widgets::inspector::row_json(&fields, &row, read.as_ref());
18405            let message = if unread > 0 {
18406                format!(
18407                    "Copied row {}: {} fields, {} not read",
18408                    thousands(display),
18409                    thousands(kept),
18410                    thousands(unread)
18411                )
18412            } else {
18413                format!("Copied row {} as JSON", thousands(display))
18414            };
18415            (json, message)
18416        };
18417        if size <= Self::FIELD_COPY_INLINE_BYTES {
18418            let (json, message) = build();
18419            self.copy_string(json, message);
18420            return;
18421        }
18422        let limit = match self.copy_destination() {
18423            Ok(destination) => destination.accepts().base64_limit,
18424            Err(e) => {
18425                self.error_modal.show(e);
18426                return;
18427            }
18428        };
18429        self.spawn_job(Job::Copy, Some("Copying..."), move |_| {
18430            let (json, message) = build();
18431            if let Some(limit) = limit
18432                && json.len() > limit / 4 * 3
18433            {
18434                return Err(clipboard::over_osc52_limit(None, limit));
18435            }
18436            Ok(Answer::Copied {
18437                payload: clipboard::Payload::text(json),
18438                message,
18439            })
18440        });
18441    }
18442
18443    /// `o` in the inspector: the value written to a file of its own, in the view
18444    /// it is shown in, for another program to open; see [`external_open`].
18445    fn open_inspected_value(&mut self) {
18446        let Some(state) = self.data_table_state.as_ref() else {
18447            return;
18448        };
18449        let Some(row) = state.inspect_row() else {
18450            return;
18451        };
18452        let Some(field) = self.inspector_modal.focused().cloned() else {
18453            return;
18454        };
18455        let column = if field.buffered() {
18456            row.values.column(&field.name).ok().cloned()
18457        } else {
18458            self.inspector_modal
18459                .read_values(row.frame, row.row)
18460                .and_then(|values| values.column(&field.name).ok().cloned())
18461        };
18462        let Some(column) = column else {
18463            self.flash_note(format!("{} is not read yet; Enter reads it", field.name));
18464            return;
18465        };
18466        if self.open_dir.is_none() {
18467            match tempfile::Builder::new().prefix("datui-values-").tempdir() {
18468                Ok(dir) => self.open_dir = Some(dir),
18469                Err(e) => {
18470                    self.flash_note(format!("Could not open the value: {e}"));
18471                    return;
18472                }
18473            }
18474        }
18475        let dir = self
18476            .open_dir
18477            .as_ref()
18478            .map(|d| d.path().to_path_buf())
18479            .unwrap_or_default();
18480        let shown_as = self.inspector_pane().map(|pane| pane.copy);
18481        let name = field.name.clone();
18482        let display = row.display_row;
18483        self.spawn_job(Job::OpenValue, Some("Writing the value..."), move |_| {
18484            use crate::widgets::inspector::CopyAs;
18485            let value = column.get(0).map_err(|e| e.to_string())?;
18486            let (bytes, extension, document): (Vec<u8>, &str, bool) = match (&shown_as, &value) {
18487                (Some(CopyAs::Text(text)), _) => {
18488                    let ext = if inspector_drill::looks_like_json(text) {
18489                        "json"
18490                    } else {
18491                        "txt"
18492                    };
18493                    (text.as_bytes().to_vec(), ext, false)
18494                }
18495                (_, polars::prelude::AnyValue::Binary(b)) => {
18496                    let kind = inspector_bytes::sniff(b);
18497                    (
18498                        b.to_vec(),
18499                        kind.map_or("bin", |k| k.extension()),
18500                        kind.is_some_and(|k| k.is_document()),
18501                    )
18502                }
18503                (_, polars::prelude::AnyValue::BinaryOwned(b)) => {
18504                    let kind = inspector_bytes::sniff(b);
18505                    (
18506                        b.clone(),
18507                        kind.map_or("bin", |k| k.extension()),
18508                        kind.is_some_and(|k| k.is_document()),
18509                    )
18510                }
18511                (_, v) if crate::exact::is_nested_value(v) => {
18512                    let text = crate::exact::copy_text(&column).map_err(|e| e.to_string())?;
18513                    (text.into_bytes(), "json", false)
18514                }
18515                (_, v) => {
18516                    let text = crate::exact::value_text(v);
18517                    let trimmed = text.trim_start();
18518                    let ext = if inspector_drill::looks_like_json(&text) {
18519                        "json"
18520                    } else if trimmed.starts_with('<') {
18521                        "xml"
18522                    } else {
18523                        "txt"
18524                    };
18525                    (text.into_bytes(), ext, false)
18526                }
18527            };
18528            let file = external_open::file_name(&name, display, extension);
18529            let path =
18530                external_open::write_value(&dir, &file, &bytes).map_err(|e| e.to_string())?;
18531            Ok(Answer::ValueWritten(external_open::ExternalOpen {
18532                path,
18533                document,
18534            }))
18535        });
18536    }
18537
18538    /// Move the table's cursor `delta` rows, reading the next page in the
18539    /// background when the buffer does not hold the row: the table's own ↑↓.
18540    fn step_row(&mut self, delta: i64) -> Option<AppEvent> {
18541        let state = self.data_table_state.as_mut()?;
18542        if state.scroll_would_trigger_collect(delta) {
18543            self.busy = true;
18544            return Some(if delta > 0 {
18545                AppEvent::DoScrollNext
18546            } else {
18547                AppEvent::DoScrollPrev
18548            });
18549        }
18550        if delta > 0 {
18551            state.select_next();
18552        } else {
18553            state.select_previous();
18554        }
18555        None
18556    }
18557
18558    /// The inspector's keys. Moving between rows moves the table's cursor, so the
18559    /// table is where the inspector left it on close.
18560    /// Open `node` as a level under the one shown: a list or struct at once, text as
18561    /// the JSON it holds, parsed here when short and on a worker when long. `path`
18562    /// is the text's place, remembered when it does not parse.
18563    fn inspector_open(
18564        &mut self,
18565        frame: u64,
18566        row: usize,
18567        label: String,
18568        path: String,
18569        node: inspector_drill::Node,
18570    ) {
18571        use inspector_drill::{JSON_INLINE_BYTES, Node, Shape};
18572        if node.shape() != Shape::Leaf {
18573            self.inspector_modal.drill_in(frame, row, label, node);
18574            return;
18575        }
18576        let Some(len) = node
18577            .with_text(|s| inspector_drill::opens_as_json(s).then_some(s.len()))
18578            .flatten()
18579        else {
18580            return;
18581        };
18582        // Short text is parsed on this key.
18583        if len <= JSON_INLINE_BYTES {
18584            match node.with_text(inspector_drill::parse_json) {
18585                Some(Ok(value)) => {
18586                    let node = Node::Json {
18587                        root: std::sync::Arc::new(value),
18588                        path: Vec::new(),
18589                    };
18590                    self.inspector_modal.drill_in(frame, row, label, node);
18591                }
18592                Some(Err(e)) => {
18593                    self.inspector_modal.not_json = Some((frame, row, path));
18594                    self.flash_note(sentence(&e));
18595                }
18596                None => {}
18597            }
18598            return;
18599        }
18600        let token = self.inspector_modal.wait_for_json(frame, row, label, path);
18601        // The worker reads the text where it is: the node is a one-row slice or a
18602        // shared document, so nothing up to the 4 MiB cap is copied to hand it over.
18603        self.spawn_job(
18604            Job::InspectJson { token },
18605            Some(Self::READING_JSON),
18606            move |_| match node.with_text(inspector_drill::parse_json) {
18607                Some(parsed) => parsed.map(|value| Answer::JsonParsed(std::sync::Arc::new(value))),
18608                None => Err("not JSON: not text".to_string()),
18609            },
18610        );
18611    }
18612
18613    /// `y` inside a drill: the focused item's whole value, exact, as `y` copies a
18614    /// field; a JSON object or array as indented JSON.
18615    fn copy_drilled_item(&mut self) {
18616        use inspector_drill::Node;
18617        let Some(drill) = self.inspector_modal.drill.as_ref() else {
18618            return;
18619        };
18620        let Some((label, node)) = drill.level().focused() else {
18621            return;
18622        };
18623        let g = crate::glyphs::get();
18624        let mut path: Vec<&str> = drill.levels.iter().map(|l| l.label.as_str()).collect();
18625        path.push(&label);
18626        let display_row = self
18627            .data_table_state
18628            .as_ref()
18629            .and_then(|s| s.inspect_row())
18630            .map_or(drill.row + 1, |r| r.display_row);
18631        let message = format!(
18632            "Copied {} of row {}",
18633            path.join(&format!(" {} ", g.trail)),
18634            copy_modal::thousands(display_row)
18635        );
18636        match node {
18637            Node::Native(series) => self.copy_value(polars::prelude::Column::from(series), message),
18638            Node::Json { .. } => {
18639                let limit = match self.copy_destination() {
18640                    Ok(destination) => destination.accepts().base64_limit,
18641                    Err(e) => {
18642                        self.error_modal.show(e);
18643                        return;
18644                    }
18645                };
18646                // Formatted here up to a size that is quick; past it, on a worker.
18647                let cap = limit.map_or(Self::JSON_COPY_MAX_BYTES, |limit| limit / 4 * 3);
18648                let quick = cap.min(Self::FIELD_COPY_INLINE_BYTES);
18649                let null = serde_json::Value::Null;
18650                let value = node.json().unwrap_or(&null);
18651                if let Some(text) = inspector_drill::json_copy_text(value, quick) {
18652                    self.finish_copy(clipboard::Payload::text(text), message);
18653                    return;
18654                }
18655                if let Some(limit) = limit.filter(|_| cap <= Self::FIELD_COPY_INLINE_BYTES) {
18656                    self.error_modal
18657                        .show(clipboard::over_osc52_limit(None, limit));
18658                    return;
18659                }
18660                // The worker resolves the path itself: the document is shared, not copied.
18661                self.spawn_job(Job::Copy, Some("Copying..."), move |_| {
18662                    let value = node.json().unwrap_or(&serde_json::Value::Null);
18663                    let text =
18664                        inspector_drill::json_copy_text(value, cap).ok_or_else(|| match limit {
18665                            Some(limit) => clipboard::over_osc52_limit(None, limit),
18666                            None => "Copy failed: the value is too large to copy".to_string(),
18667                        })?;
18668                    Ok(Answer::Copied {
18669                        payload: clipboard::Payload::text(text),
18670                        message,
18671                    })
18672                });
18673            }
18674        }
18675    }
18676
18677    /// Read, off this thread, the fields of `row` the buffer does not hold — the
18678    /// hidden columns and the binary ones — with the shown columns beside them, so a
18679    /// sort that orders ties differently on a second read cannot pass another row's
18680    /// fields off as this one's. `wait`: the user waits on it, as on Enter; a read
18681    /// that follows the rows does not hold the keys.
18682    fn read_inspected_fields(&mut self, row: &crate::widgets::datatable::InspectRow, wait: bool) {
18683        let Some(state) = self.data_table_state.as_ref() else {
18684            return;
18685        };
18686        let fields = state.inspect_fields();
18687        let wanted: Vec<String> = fields
18688            .iter()
18689            .filter(|f| !f.buffered())
18690            .map(|f| f.name.clone())
18691            .collect();
18692        let check: Vec<String> = fields
18693            .iter()
18694            .filter(|f| f.buffered())
18695            .map(|f| f.name.clone())
18696            .collect();
18697        let columns: Vec<String> = wanted.iter().chain(check.iter()).cloned().collect();
18698        let lf = match state.inspect_read_lf(row.row, &columns) {
18699            Ok(lf) => lf,
18700            Err(e) => {
18701                self.inspector_modal.read = Some(inspector_modal::FieldRead::Failed {
18702                    frame: row.frame,
18703                    row: row.row,
18704                    message: crate::error_display::user_message_from_polars(&e),
18705                });
18706                return;
18707            }
18708        };
18709        let expected = row.values.select(check.iter().map(String::as_str)).ok();
18710        let streaming = state.polars_streaming();
18711        let (frame, index) = (row.frame, row.row);
18712        self.inspector_modal.read = Some(inspector_modal::FieldRead::Reading { frame, row: index });
18713        self.spawn_job(
18714            Job::InspectRow { frame, row: index },
18715            wait.then_some(Self::READING_FIELDS),
18716            move |_| {
18717                let read = crate::statistics::collect_lazy(lf, streaming)
18718                    .map_err(|e| crate::error_display::user_message_from_polars(&e))?;
18719                if read.height() != 1 {
18720                    return Err("the row is no longer in the view".to_string());
18721                }
18722                let same = expected.is_some_and(|expected| {
18723                    read.select(check.iter().map(String::as_str))
18724                        .is_ok_and(|again| again.equals_missing(&expected))
18725                });
18726                if !same {
18727                    return Err("the view's order of equal rows changed on reading again; \
18728                         sort by a column that tells the rows apart"
18729                        .to_string());
18730                }
18731                let values = read
18732                    .select(wanted.iter().map(String::as_str))
18733                    .map_err(|e| e.to_string())?;
18734                Ok(Answer::FieldsRead(values))
18735            },
18736        );
18737    }
18738
18739    /// Copy the one value of `column`, exact, and flash `message`.
18740    fn copy_value(&mut self, column: polars::prelude::Column, message: String) {
18741        // Destination first, as the copy dialog's: a value over the terminal's cap
18742        // is refused before it is formatted, here or on a worker.
18743        let limit = match self.copy_destination() {
18744            Ok(destination) => destination.accepts().base64_limit,
18745            Err(e) => {
18746                self.error_modal.show(e);
18747                return;
18748            }
18749        };
18750        if let Some(limit) = limit {
18751            let fits = limit / 4 * 3;
18752            let over = column
18753                .get(0)
18754                .is_ok_and(|value| crate::exact::copy_len_floor(&value, fits) > fits);
18755            if over {
18756                self.error_modal
18757                    .show(clipboard::over_osc52_limit(None, limit));
18758                return;
18759            }
18760        }
18761        if column.as_materialized_series().estimated_size() <= Self::FIELD_COPY_INLINE_BYTES {
18762            match crate::exact::copy_text(&column) {
18763                Ok(text) => self.finish_copy(clipboard::Payload::text(text), message),
18764                Err(e) => self.error_modal.show(format!("Copy failed: {e}")),
18765            }
18766            return;
18767        }
18768        self.spawn_job(Job::Copy, Some("Copying..."), move |_| {
18769            let text = crate::exact::copy_text(&column).map_err(|e| format!("Copy failed: {e}"))?;
18770            Ok(Answer::Copied {
18771                payload: clipboard::Payload::text(text),
18772                message,
18773            })
18774        });
18775    }
18776
18777    /// Replace the clipboard destination, so tests can watch what a copy sends
18778    /// without a display server or a terminal in the loop.
18779    pub fn set_clipboard_destination(&mut self, destination: Box<dyn clipboard::Destination>) {
18780        self.clipboard = Some(destination);
18781    }
18782
18783    pub fn create_view_from_current_state(
18784        &mut self,
18785        name: String,
18786        description: Option<String>,
18787        match_criteria: view::MatchCriteria,
18788    ) -> Result<view::SavedView> {
18789        let settings = match &self.data_table_state {
18790            Some(state) => view::ViewSettings {
18791                chart: self.saved_chart(),
18792                ..view_settings_of(state)
18793            },
18794            None => view::ViewSettings {
18795                chart: None,
18796                sample: None,
18797                query: None,
18798                sql_query: None,
18799                fuzzy_query: None,
18800                filters: Vec::new(),
18801                sort_columns: Vec::new(),
18802                sort_descending: Vec::new(),
18803                sort_ascending: true,
18804                column_order: Vec::new(),
18805                locked_columns_count: 0,
18806                pivot: None,
18807                melt: None,
18808                reshape_source: None,
18809                columns: Vec::new(),
18810            },
18811        };
18812
18813        self.view_manager
18814            .create_view(name, description, match_criteria, settings)
18815    }
18816
18817    /// The command line's language while it is open.
18818    pub fn query_prompt_mode(&self) -> Option<QueryMode> {
18819        (self.input_mode == InputMode::Editing && self.input_type == Some(InputType::Query))
18820            .then_some(self.query_mode)
18821    }
18822
18823    /// `:` at the table: the command line, holding the query in effect, selected,
18824    /// so typing states a new one and the arrows edit it.
18825    pub(crate) fn open_command_line(&mut self) {
18826        let Some(state) = self.data_table_state.as_mut() else {
18827            return;
18828        };
18829        self.input_mode = InputMode::Editing;
18830        self.input_type = Some(InputType::Query);
18831        self.query_run_error = None;
18832        self.sql_completion = None;
18833        self.query_input.set_value(state.get_active_query());
18834        self.sql_input.set_value(state.get_active_sql_query());
18835        self.query_input.select_all();
18836        self.sql_input.select_all();
18837        state.suppress_error_display = true;
18838        self.sql_columns = state.sql_table_columns();
18839        self.query_mode = self.opening_query_mode();
18840        self.query_text_restored = !self.query_input_shown().is_empty();
18841        self.sync_query_focus();
18842    }
18843
18844    /// The language `:` opens in: the query in effect's own, so editing never
18845    /// reinterprets it; else the one Ctrl+T last chose; else the configured default.
18846    fn opening_query_mode(&self) -> QueryMode {
18847        let active = self.data_table_state.as_ref().and_then(|state| {
18848            if !state.get_active_sql_query().trim().is_empty() {
18849                Some(QueryMode::Sql)
18850            } else if !state.get_active_query().trim().is_empty() {
18851                Some(QueryMode::Q)
18852            } else {
18853                None
18854            }
18855        });
18856        active
18857            .or(self.query_mode_chosen)
18858            .unwrap_or(self.app_config.query.default_mode)
18859            .resolve()
18860    }
18861
18862    /// The command line's input for its current language.
18863    fn query_input_mut(&mut self) -> &mut TextInput {
18864        match self.query_mode {
18865            QueryMode::Sql => &mut self.sql_input,
18866            QueryMode::Q => &mut self.query_input,
18867        }
18868    }
18869
18870    /// The command line's input for its current language.
18871    pub(crate) fn query_input_shown(&self) -> &TextInput {
18872        match self.query_mode {
18873            QueryMode::Sql => &self.sql_input,
18874            QueryMode::Q => &self.query_input,
18875        }
18876    }
18877
18878    /// Switch the prompt's mode. Each mode keeps its own text; an error from the
18879    /// last run belongs to the mode that ran it.
18880    fn set_query_mode(&mut self, mode: QueryMode) {
18881        self.query_mode = mode.resolve();
18882        if let Some(state) = &mut self.data_table_state {
18883            state.dismiss_error();
18884        }
18885        self.query_run_error = None;
18886        self.sync_query_focus();
18887    }
18888
18889    /// Tab in the command line: complete the column name (or, in SQL, the table
18890    /// name) being typed, and on further presses step through the other names that
18891    /// match.
18892    fn complete_column_name(&mut self) {
18893        let sql = self.query_mode == QueryMode::Sql;
18894        let columns = std::mem::take(&mut self.sql_columns);
18895        let mut cycle = self.sql_completion.take();
18896        let input = self.query_input_mut();
18897        let line = input
18898            .line_at(input.cursor_line())
18899            .unwrap_or_default()
18900            .to_string();
18901        let value = input.value().to_string();
18902        let complete = if sql {
18903            sql_assist::tab
18904        } else {
18905            sql_assist::q_tab
18906        };
18907        if let Some(step) = complete(
18908            &columns,
18909            &line,
18910            input.cursor_col(),
18911            &value,
18912            input.cursor(),
18913            &mut cycle,
18914        ) {
18915            input.replace_before_cursor(step.span, &step.insert);
18916            sql_assist::landed(&mut cycle, input.value(), input.cursor());
18917        }
18918        self.sql_completion = cycle;
18919        self.sql_columns = columns;
18920    }
18921
18922    /// The columns of `df` the word at the command line's cursor could name, for
18923    /// the list under the input: every column while nothing is being typed.
18924    pub(crate) fn sql_column_matches(&self) -> Vec<&(String, DataType)> {
18925        let input = self.query_input_shown();
18926        let line = input.line_at(input.cursor_line()).unwrap_or_default();
18927        let word = match self.query_mode {
18928            QueryMode::Sql => sql_assist::word_before(line, input.cursor_col()),
18929            QueryMode::Q => sql_assist::q_word_before(line, input.cursor_col()),
18930        }
18931        .map(|w| w.text)
18932        .unwrap_or_default();
18933        sql_assist::matching(&self.sql_columns, &word)
18934    }
18935
18936    /// The command line's text, while it is open.
18937    pub fn query_prompt_text(&self) -> Option<&str> {
18938        self.query_prompt_mode()?;
18939        Some(self.query_input_shown().value())
18940    }
18941
18942    /// Why the last run failed, for the line under the input: a statement that
18943    /// failed while running, else one that could not be planned.
18944    pub fn query_prompt_error(&self) -> Option<String> {
18945        if let Some(error) = &self.query_run_error {
18946            return Some(error.clone());
18947        }
18948        let state = self.data_table_state.as_ref()?;
18949        let error = state.error()?;
18950        Some(if self.query_mode == QueryMode::Sql {
18951            crate::error_display::sql_error_message(error, state.sql_table_rows())
18952        } else {
18953            crate::error_display::user_message_from_polars(error)
18954        })
18955    }
18956
18957    /// Bumped each time a running statement's failure is put in the prompt.
18958    pub fn inline_failures(&self) -> u64 {
18959        self.inline_failures
18960    }
18961
18962    /// Plan a query in `mode` and read its first rows in the background. A query that
18963    /// cannot be planned leaves its error on the state, where the prompt shows it. One
18964    /// that plans stays pending — the prompt open, when it came from there — until its
18965    /// rows are in; if they fail, the view it replaced comes back.
18966    fn run_query(&mut self, mode: QueryMode, text: &str, status: &str) {
18967        self.query_run_error = None;
18968        let Some(state) = self.data_table_state.as_mut() else {
18969            return;
18970        };
18971        let rollback = state.rollback_point();
18972        let rows = state.sql_table_rows();
18973        // A query that cannot be planned changes nothing and leaves its error showing.
18974        state.deferred(|s| match mode {
18975            QueryMode::Sql => s.sql_query(text.to_string()),
18976            QueryMode::Q => s.query(text.to_string()),
18977        });
18978        if state.error().is_some() {
18979            return;
18980        }
18981        self.query_running = Some(QueryRun {
18982            origin: RunOrigin::Query(mode),
18983            frame: state.len_generation(),
18984            rollback,
18985            len_count_inflight: self.len_count_inflight,
18986            count_after_paint: self.count_after_paint,
18987            len_count_failed: self.len_count_failed,
18988            rows,
18989        });
18990        if !self.spawn_async_collect(status) {
18991            // Nothing to read: the rows on hand already show it.
18992            self.query_running = None;
18993            if self.query_prompt_mode() == Some(mode) {
18994                self.leave_query_prompt_after_run();
18995            }
18996        }
18997    }
18998
18999    /// The query still running over the frame on screen, taken. One whose frame has
19000    /// since been replaced is dropped: its rollback would undo what replaced it.
19001    fn take_query_run(&mut self) -> Option<QueryRun> {
19002        let run = self.query_running.take()?;
19003        let frame = self.data_table_state.as_ref()?.len_generation();
19004        (run.frame == frame).then_some(run)
19005    }
19006
19007    /// A query ran and its rows are in: the prompt closes on them.
19008    fn leave_query_prompt_after_run(&mut self) {
19009        self.sql_completion = None;
19010        self.input_mode = InputMode::Normal;
19011        self.input_type = None;
19012        self.sql_input.set_focused(false);
19013        self.query_input.set_focused(false);
19014        if let Some(state) = &mut self.data_table_state {
19015            state.suppress_error_display = false;
19016        }
19017    }
19018
19019    /// Only the current language's input carries the cursor.
19020    fn sync_query_focus(&mut self) {
19021        let mode = self.query_mode;
19022        self.sql_input.set_focused(mode == QueryMode::Sql);
19023        self.query_input.set_focused(mode == QueryMode::Q);
19024    }
19025
19026    /// Esc from anywhere in the prompt: nothing runs and nothing typed survives.
19027    fn close_query_prompt(&mut self) {
19028        self.query_run_error = None;
19029        self.sql_completion = None;
19030        self.query_input.clear();
19031        self.sql_input.clear();
19032        self.query_input.set_focused(false);
19033        self.sql_input.set_focused(false);
19034        self.input_mode = InputMode::Normal;
19035        self.input_type = None;
19036        if let Some(state) = &mut self.data_table_state {
19037            state.dismiss_error();
19038            state.suppress_error_display = false;
19039        }
19040    }
19041}
19042
19043impl Widget for &mut App {
19044    fn render(self, area: Rect, buf: &mut Buffer) {
19045        self.begin_frame();
19046        self.debug.num_frames += 1;
19047        if self.debug.enabled {
19048            self.debug.show_help_at_render = self.help.is_open();
19049        }
19050
19051        use crate::render::context::RenderContext;
19052        use crate::render::layout::app_layout;
19053        use crate::render::main_view::MainViewContent;
19054
19055        let ctx = RenderContext::from_theme_and_config(
19056            &self.theme,
19057            self.table_cell_padding,
19058            self.column_colors,
19059            self.number_format.clone(),
19060        )
19061        .with_dtype_row(self.dtype_row);
19062
19063        let main_view_content = MainViewContent::current(self);
19064
19065        Clear.render(area, buf);
19066        let background_color = self.color("background");
19067        Block::default()
19068            .style(Style::default().bg(background_color))
19069            .render(area, buf);
19070
19071        // The footer grows, taking rows from the bottom of the view, only for a prompt
19072        // being typed or a job with progress.
19073        let progress = self.footer_progress_line(main_view_content);
19074        let prompt_room = crate::render::footer::MAX_LINES - 1;
19075        let prompt_rows = if main_view_content == MainViewContent::Datatable {
19076            crate::render::input_strip::rows(self, area.width, prompt_room)
19077        } else {
19078            0
19079        };
19080        let progress_rows = u16::from(progress.is_some() && prompt_rows < prompt_room);
19081        let footer_lines = 1 + prompt_rows + progress_rows;
19082        // The inspector is framed; its border sets it off from the footer.
19083        let rule = self.input_mode != InputMode::Inspect;
19084        let app_layout = app_layout(area, self.debug.enabled, footer_lines, rule);
19085        // A terminal too short for all of it keeps the status line first, then the
19086        // prompt, then the progress.
19087        let room = app_layout.footer.height.saturating_sub(1);
19088        let prompt_rows = prompt_rows.min(room);
19089        let progress_rows = progress_rows.min(room - prompt_rows);
19090        let main_area = app_layout.main_view;
19091        Clear.render(main_area, buf);
19092
19093        crate::render::main_view_render::render_main_view(area, main_area, buf, self, &ctx);
19094        if self.in_normal_table_view() {
19095            self.render_drop_mark(buf, &ctx);
19096        }
19097        if self.menu_showing()
19098            && let Some(menu) = self.context_menu.clone()
19099        {
19100            menu.render(main_area, buf, &ctx);
19101        }
19102
19103        // Status messages are shown inline in the control bar (no overlay popups).
19104
19105        if self.confirmation_modal.active {
19106            crate::render::overlays::render_confirmation_modal(
19107                area,
19108                buf,
19109                &mut self.confirmation_modal,
19110                &ctx,
19111            );
19112        }
19113        if self.error_modal.active {
19114            crate::render::overlays::render_error_modal(area, buf, &mut self.error_modal, &ctx);
19115        }
19116        self.close_help_left_behind();
19117        if self.help.is_open() {
19118            // Over the view, never the footer: its rule, and the lines it grows by
19119            // for a prompt or progress, are drawn after and would cut the frame.
19120            crate::render::help::render_help(app_layout.main_view, buf, &mut self.help, &ctx);
19121        }
19122
19123        let footer = self.footer(main_view_content, progress_rows > 0);
19124        crate::render::footer::render_rule(app_layout.rule, buf, &ctx);
19125        let line = Rect {
19126            height: 1,
19127            ..app_layout.footer
19128        };
19129        let drawn = footer.render_line(line, buf, &ctx);
19130        if prompt_rows > 0 {
19131            crate::render::input_strip::render(
19132                Rect {
19133                    y: line.y + 1,
19134                    height: prompt_rows,
19135                    ..line
19136                },
19137                buf,
19138                self,
19139                &ctx,
19140            );
19141        }
19142        if let Some(progress) = progress.filter(|_| progress_rows > 0) {
19143            crate::render::footer::render_progress(
19144                &progress,
19145                Rect {
19146                    y: line.y + 1 + prompt_rows,
19147                    height: 1,
19148                    ..line
19149                },
19150                buf,
19151                &ctx,
19152            );
19153        }
19154        self.pointer.chips_drawn(
19155            drawn
19156                .iter()
19157                .map(|(rect, key)| (*rect, key.as_str()))
19158                .collect(),
19159        );
19160        if let Some(debug_area) = app_layout.debug {
19161            self.debug.render(debug_area, buf);
19162        }
19163        self.pointer.drawn();
19164
19165        // Last line of defence, and deliberately the last statement here.
19166        //
19167        // Everything above draws untrusted text: cell values, column names,
19168        // filenames, parser messages. ratatui strips control characters in
19169        // `Buffer::set_stringn` but not in `Span`/`Line` rendering, which is
19170        // what these widgets use, and the crossterm backend then prints each
19171        // cell symbol unfiltered. Without this sweep a cell containing
19172        // `\x1b]52;c;...\x07` writes to the user's clipboard.
19173        //
19174        // Doing it here rather than at each of the ~200 `Span` construction
19175        // sites means a new widget cannot forget to. See `crate::sanitize`.
19176        crate::sanitize::sanitize_buffer(buf);
19177    }
19178}
19179
19180impl App {
19181    /// The view a caller that asked for one gets back when the app exits
19182    /// (`datui.view(..., capture=True)`): the active table's committed frame with
19183    /// datui's internal columns dropped. `None` when no dataset is open. Text still
19184    /// sitting in an editor was never applied, so it is not here either.
19185    ///
19186    /// Refused when the frame would scan a temporary file, because those are removed
19187    /// on exit and a plan over deleted paths fails later and worse: a remote download,
19188    /// a decompressed archive, or a converted stream or GPS log. The in-TUI export (`e`) writes
19189    /// real rows and is the way out for those datasets.
19190    pub fn capture_view(&self) -> Result<Option<LazyFrame>> {
19191        let Some(state) = &self.data_table_state else {
19192            return Ok(None);
19193        };
19194        if state.scans_a_download() {
19195            return Err(color_eyre::eyre::eyre!(
19196                "cannot return this view: the data was downloaded to a temporary file \
19197                 that is removed when datui exits. Export it from inside datui (press \
19198                 e) instead."
19199            ));
19200        }
19201        if state.scans_a_temp_file() {
19202            return Err(color_eyre::eyre::eyre!(
19203                "cannot return this view: the data was decompressed or converted into a \
19204                 temporary file that is removed when datui exits. Export it from \
19205                 inside datui (press e) instead."
19206            ));
19207        }
19208        Ok(Some(state.visible_lf()))
19209    }
19210}
19211
19212impl Drop for App {
19213    fn drop(&mut self) {
19214        // The dataset's footer pass stops issuing reads. The open in flight is the
19215        // loader's, which stops it as it drops: a download still running stops at its
19216        // next chunk and removes its partial file, and a finished one is removed as
19217        // whatever holds it drops. Drop rather than the end of `run`, because it covers
19218        // every exit: a normal quit, an error return, an unwind from a panic, and the
19219        // Python binding calling `run` again in the same process.
19220        self.footer_progress.cancel();
19221        // The indexing stops, and the reads waiting on it give up, so nothing holds
19222        // the file once the app is gone (the Python binding runs on in the process).
19223        self.indexing_stop
19224            .store(true, std::sync::atomic::Ordering::Relaxed);
19225        if let Some(lines) = self.indexing_lines.take() {
19226            lines.stop_indexing();
19227        }
19228    }
19229}
19230
19231/// A source as a home-screen row, with the last run's buckets when they still apply.
19232#[cfg(feature = "cloud")]
19233fn home_cloud_source(
19234    source: &crate::cloud_sources::Source,
19235    cached: Option<&crate::cache::CloudListing>,
19236    listing: bool,
19237) -> home::CloudSource {
19238    let mut details: Vec<(String, String)> = vec![
19239        ("source".to_string(), source.id.clone()),
19240        (
19241            "api".to_string(),
19242            match source.kind {
19243                crate::cloud_browse::ProviderKind::S3 => "s3",
19244                crate::cloud_browse::ProviderKind::Gcs => "gcs",
19245                crate::cloud_browse::ProviderKind::Azure => "azure",
19246            }
19247            .to_string(),
19248        ),
19249    ];
19250    if let Some(endpoint) = &source.s3.endpoint {
19251        details.push(("endpoint".to_string(), endpoint.clone()));
19252    }
19253    if let Some(region) = &source.s3.region {
19254        details.push(("region".to_string(), region.clone()));
19255    }
19256    if let Some(project) = &source.project {
19257        details.push(("project".to_string(), project.clone()));
19258    }
19259    if let Some(profile) = &source.profile {
19260        details.push(("profile".to_string(), profile.clone()));
19261    }
19262    if let Some(configuration) = &source.gcloud {
19263        details.push(("configuration".to_string(), configuration.clone()));
19264    }
19265    if source.s3.virtual_hosted.is_some() {
19266        let style = if source.s3.virtual_hosted_style() {
19267            "virtual-hosted"
19268        } else {
19269            "path-style"
19270        };
19271        details.push(("addressing".to_string(), style.to_string()));
19272    }
19273    details.push(("login".to_string(), source.origin.clone()));
19274
19275    let short = source.problem.as_deref().map(|problem| {
19276        if problem.starts_with("not signed in") {
19277            "not signed in"
19278        } else if problem.starts_with("unsupported login") {
19279            "unsupported login"
19280        } else {
19281            "not configured"
19282        }
19283    });
19284    // A source that cannot list says what to do about it where the row has room: the
19285    // count already carries the short problem, and the login it would use is moot
19286    // (#547 D5).
19287    let note = match (&source.problem, short) {
19288        (Some(problem), Some(short)) => problem
19289            .strip_prefix(short)
19290            .map(|rest| rest.trim_start_matches([':', ' ']))
19291            .filter(|rest| !rest.is_empty())
19292            .unwrap_or(problem)
19293            .to_string(),
19294        _ => [source.detail(), Some(source.origin.clone())]
19295            .into_iter()
19296            .flatten()
19297            .filter(|n| !n.is_empty())
19298            .collect::<Vec<_>>()
19299            .join(&format!(" {} ", crate::glyphs::get().middot)),
19300    };
19301    let mut names: Vec<String> = cached.map(|c| c.buckets.clone()).unwrap_or_default();
19302    for bucket in &source.buckets {
19303        if !names.contains(bucket) {
19304            names.push(bucket.clone());
19305        }
19306    }
19307    let status = match (&source.problem, short) {
19308        (Some(problem), Some(short)) => home::CloudStatus::Failed {
19309            short: short.to_string(),
19310            detail: problem.clone(),
19311        },
19312        _ if cached.is_some() => home::CloudStatus::Listed,
19313        _ if listing => home::CloudStatus::Listing,
19314        _ => home::CloudStatus::Unlisted,
19315    };
19316    home::CloudSource {
19317        id: source.id.clone(),
19318        label: source.label.clone(),
19319        api: match source.kind {
19320            crate::cloud_browse::ProviderKind::S3 => "s3",
19321            crate::cloud_browse::ProviderKind::Gcs => "gcs",
19322            crate::cloud_browse::ProviderKind::Azure => "azure",
19323        }
19324        .to_string(),
19325        note,
19326        buckets: names
19327            .iter()
19328            .map(|b| PathBuf::from(source.bucket_url(b)))
19329            .collect(),
19330        refreshing: listing && cached.is_some() && source.problem.is_none(),
19331        // A source that failed before any request has nothing to ask.
19332        asked: listing || source.problem.is_some(),
19333        listed_at: cached
19334            .map(|c| std::time::UNIX_EPOCH + std::time::Duration::from_secs(c.listed_at)),
19335        status,
19336        details,
19337        place_details: Default::default(),
19338    }
19339}
19340
19341/// A listing error as a word for the row and the full message for the details pane.
19342#[cfg(feature = "cloud")]
19343fn summarize_cloud_failure(error: &str) -> (String, String) {
19344    let lower = error.to_lowercase();
19345    // A missing tool is already as short as it gets: `needs the AWS CLI`.
19346    if let Some(start) = lower.find("needs ") {
19347        return (error[start..].to_string(), error.to_string());
19348    }
19349    let short = if lower.contains("403")
19350        || lower.contains("forbidden")
19351        || lower.contains("accessdenied")
19352        || lower.contains("access denied")
19353    {
19354        "403"
19355    } else if lower.contains("401")
19356        || lower.contains("unauthorized")
19357        || lower.contains("credential")
19358        || lower.contains("invalidaccesskeyid")
19359        || lower.contains("expired")
19360        || lower.contains("sso")
19361        || lower.contains("az login")
19362    {
19363        "not logged in"
19364    } else if lower.contains("unsupported login") {
19365        "unsupported login"
19366    } else if lower.contains("no gcp project") {
19367        "no project"
19368    } else if lower.contains("is not set") {
19369        "not configured"
19370    } else if lower.contains("timed out")
19371        || lower.contains("timeout")
19372        || lower.contains("connection")
19373        || lower.contains("dns")
19374        || lower.contains("resolve")
19375    {
19376        "unavailable"
19377    } else {
19378        "error"
19379    };
19380    (short.to_string(), error.to_string())
19381}
19382
19383/// Run a future on the app's runtime from a thread outside it, and wait for the answer.
19384///
19385/// Every background thread that needs the network goes through this rather than
19386/// `Handle::block_on`. That polls the future on the calling thread, and quitting shuts
19387/// the runtime down without waiting for those threads: the next timer or socket an
19388/// in-flight request touches then panics with "A Tokio 1.x context was found, but it is
19389/// being shutdown", across the terminal the user just got back. A task spawned onto the
19390/// runtime is dropped by the shutdown instead of polled, so the wait ends with `None`
19391/// and the abandoned request goes quietly.
19392#[cfg(feature = "cloud")]
19393pub(crate) fn wait_on_runtime<F>(runtime: &tokio::runtime::Handle, future: F) -> Option<F::Output>
19394where
19395    F: std::future::Future + Send + 'static,
19396    F::Output: Send + 'static,
19397{
19398    let (tx, rx) = std::sync::mpsc::sync_channel(1);
19399    runtime.spawn(async move {
19400        let _ = tx.send(future.await);
19401    });
19402    rx.recv().ok()
19403}
19404
19405/// Restore the terminal, then turn how the loop ended into what `run_impl` returns.
19406/// The reader stops first, so nothing typed after the screen is handed back is read
19407/// here. The capture is taken after the screen is handed back, so a refused capture
19408/// still leaves the terminal usable.
19409fn conclude(
19410    end: event_pump::Ended,
19411    app: &App,
19412    capture: bool,
19413    reader: &mut terminal_input::TerminalInput,
19414    screen: &mut TakenTerminal,
19415) -> Result<Option<LazyFrame>> {
19416    reader.stop();
19417    screen.restore();
19418    match end {
19419        event_pump::Ended::Quit if capture => app.capture_view(),
19420        event_pump::Ended::Quit => Ok(None),
19421        event_pump::Ended::Crash(msg) => Err(color_eyre::eyre::eyre!(msg)),
19422        event_pump::Ended::NotFound(path) => Err(std::io::Error::new(
19423            std::io::ErrorKind::NotFound,
19424            format!("File not found: {}", path.display()),
19425        )
19426        .into()),
19427    }
19428}
19429
19430/// The exit status the session's ending signal calls for, once one has ended it;
19431/// 0 until then. Set once.
19432static ENDED_BY_SIGNAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
19433
19434/// The exit status for the binary when a signal ended the session [`run`] returned
19435/// from: `128 + n` for SIGTERM, SIGHUP or SIGINT, as if it had not been caught (the
19436/// statuses of `datui_cli::exit`), and on
19437/// Windows the status a console process closed by its window ends with.
19438pub fn ended_by_signal() -> Option<i32> {
19439    let status = ENDED_BY_SIGNAL.load(std::sync::atomic::Ordering::SeqCst);
19440    (status != 0).then_some(status)
19441}
19442
19443/// A signal that ends the session arrived: quit as `q` does, so the screen is handed
19444/// back and an open's temp files are removed (#510). A second one, or a session still
19445/// running a few seconds after the first, ends the process at once, as the signal
19446/// would have: a stuck event loop cannot make datui unkillable. Called on the runtime.
19447fn end_session(status: i32, tx: &std::sync::mpsc::Sender<AppEvent>) {
19448    use std::sync::atomic::Ordering;
19449    // Longer than the exit sweep's grace, which is part of a normal quit, and short
19450    // of the five seconds Windows allows a console process it is closing.
19451    const STRAGGLE: std::time::Duration = std::time::Duration::from_secs(3);
19452    fn end_now(status: i32) -> ! {
19453        restore_terminal();
19454        std::process::exit(status)
19455    }
19456    if ENDED_BY_SIGNAL
19457        .compare_exchange(0, status, Ordering::SeqCst, Ordering::SeqCst)
19458        .is_err()
19459    {
19460        end_now(ENDED_BY_SIGNAL.load(Ordering::SeqCst));
19461    }
19462    let _ = tx.send(AppEvent::Exit);
19463    tokio::spawn(async move {
19464        tokio::time::sleep(STRAGGLE).await;
19465        end_now(status);
19466    });
19467}
19468
19469/// End the session on SIGTERM, SIGHUP (the terminal closing) or SIGINT (`kill -INT`;
19470/// Ctrl+C at the terminal is a key, not this signal).
19471#[cfg(unix)]
19472fn quit_on_signals(runtime: &tokio::runtime::Handle, tx: &std::sync::mpsc::Sender<AppEvent>) {
19473    use tokio::signal::unix::{SignalKind, signal};
19474    // `signal` registers with the runtime it is called in.
19475    let _runtime = runtime.enter();
19476    for kind in [
19477        SignalKind::terminate(),
19478        SignalKind::hangup(),
19479        SignalKind::interrupt(),
19480    ] {
19481        let Ok(mut arrivals) = signal(kind) else {
19482            continue;
19483        };
19484        let tx = tx.clone();
19485        runtime.spawn(async move {
19486            while arrivals.recv().await.is_some() {
19487                end_session(128 + kind.as_raw_value(), &tx);
19488            }
19489        });
19490    }
19491}
19492
19493/// End the session when its console window is closed, or the user logs off or the
19494/// machine shuts down. Tokio holds the control handler until the process exits, so
19495/// the quit runs before Windows ends it.
19496#[cfg(windows)]
19497fn quit_on_signals(runtime: &tokio::runtime::Handle, tx: &std::sync::mpsc::Sender<AppEvent>) {
19498    use tokio::signal::windows::{ctrl_close, ctrl_logoff, ctrl_shutdown};
19499    /// STATUS_CONTROL_C_EXIT, what a console process ends with when closed.
19500    const CLOSED: i32 = 0xC000_013A_u32 as i32;
19501    // Each registers with the runtime it is called in.
19502    let _runtime = runtime.enter();
19503    // Three listener types with one shape and no trait in common.
19504    macro_rules! quit_on {
19505        ($listen:expr) => {
19506            if let Ok(mut arrivals) = $listen {
19507                let tx = tx.clone();
19508                runtime.spawn(async move {
19509                    while arrivals.recv().await.is_some() {
19510                        end_session(CLOSED, &tx);
19511                    }
19512                });
19513            }
19514        };
19515    }
19516    quit_on!(ctrl_close());
19517    quit_on!(ctrl_logoff());
19518    quit_on!(ctrl_shutdown());
19519}
19520
19521/// Run the TUI with either file paths or an existing LazyFrame. Single event loop
19522/// used by the CLI and the Python binding.
19523pub fn run(input: RunInput, config: Option<AppConfig>) -> Result<()> {
19524    run_impl(input, config, false).map(|_| ())
19525}
19526
19527/// As `run`, but a normal quit hands back the active table's final view for the
19528/// caller to keep working with (the Python binding's `capture=True`). `None` when no
19529/// dataset was open at quit. See `App::capture_view` for what is refused and why.
19530pub fn run_captured(input: RunInput, config: Option<AppConfig>) -> Result<Option<LazyFrame>> {
19531    run_impl(input, config, true)
19532}
19533
19534fn run_impl(
19535    input: RunInput,
19536    config: Option<AppConfig>,
19537    capture: bool,
19538) -> Result<Option<LazyFrame>> {
19539    use event_pump::EventPump;
19540    use std::io::Write;
19541
19542    // First, so a missing file is named as the home directory has it.
19543    let input = startup::expand_home(input);
19544    use std::sync::{Mutex, Once, mpsc};
19545
19546    // The saved views are read on a worker from here; the first thing that needs them
19547    // waits for the rest of the read, if any.
19548    let views = Views::read_in_background();
19549
19550    // Install color_eyre at most once per process (e.g. first datui.view() in Python).
19551    // Subsequent run() calls skip install and reuse the result; no error-message detection.
19552    static COLOR_EYRE_INIT: Once = Once::new();
19553    static INSTALL_RESULT: Mutex<Option<Result<(), color_eyre::Report>>> = Mutex::new(None);
19554    COLOR_EYRE_INIT.call_once(|| {
19555        *INSTALL_RESULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(color_eyre::install());
19556    });
19557    if let Some(Err(e)) = INSTALL_RESULT
19558        .lock()
19559        .unwrap_or_else(|e| e.into_inner())
19560        .as_ref()
19561    {
19562        return Err(color_eyre::eyre::eyre!(e.to_string()));
19563    }
19564    let rt = tokio::runtime::Builder::new_multi_thread()
19565        .worker_threads(2)
19566        .enable_all()
19567        .build()
19568        .map_err(|e| color_eyre::eyre::eyre!("Failed to create tokio runtime: {}", e))?;
19569
19570    // Background work (e.g. the row-count `len()` over a huge or remote dataset) runs on
19571    // the runtime's blocking pool. Dropping the runtime normally *joins* those threads, so
19572    // quitting would hang until an in-flight count finished — minutes for a 474 GB hive
19573    // set. Shut the runtime down in the background instead: exit is immediate and the
19574    // abandoned read-only task dies with the process. This guard covers every return path
19575    // (Exit, Crash, `?`-propagated errors, channel disconnect).
19576    struct RtGuard(Option<tokio::runtime::Runtime>);
19577    impl Drop for RtGuard {
19578        fn drop(&mut self) {
19579            if let Some(rt) = self.0.take() {
19580                rt.shutdown_background();
19581            }
19582        }
19583    }
19584    let rt_guard = RtGuard(Some(rt));
19585    let rt_handle = rt_guard
19586        .0
19587        .as_ref()
19588        .expect("runtime present")
19589        .handle()
19590        .clone();
19591
19592    // `--tee -` passes the stream on to standard output, so the screen is drawn on the
19593    // terminal itself; standard output as it was is kept for the copy.
19594    let passed = match &input {
19595        RunInput::Cli(args) if args.tee.as_deref().is_some_and(crate::stdin::is_stdin) => {
19596            Some(crate::tee::pass_stdout_on().map_err(|e| color_eyre::eyre::eyre!(e))?)
19597        }
19598        _ => None,
19599    };
19600    let mut terminal = match ratatui::try_init() {
19601        Ok(terminal) => QuietTerminal(Some(terminal)),
19602        Err(e) => {
19603            // No screen to keep up, so nothing to wait behind: a configuration that
19604            // cannot be used, or a named file that is not there, is the more useful
19605            // thing to say, as each always came first.
19606            if config.is_none() {
19607                startup::load_config(&input)?;
19608            }
19609            // Without a screen nothing is opened, so the specs are not loaded to look.
19610            if let Some(missing) =
19611                App::missing_named_path(startup::named_paths(&input), &Default::default())
19612            {
19613                return Err(std::io::Error::new(
19614                    std::io::ErrorKind::NotFound,
19615                    format!("File not found: {}", missing.display()),
19616                )
19617                .into());
19618            }
19619            return Err(color_eyre::eyre::eyre!(
19620                "datui requires an interactive terminal (TTY). No terminal detected: {}. \
19621                 There is no TTY inside a Jupyter notebook or when output is piped or \
19622                 redirected; run from a terminal with stdout connected to it.",
19623                e
19624            ));
19625        }
19626    };
19627    // Handed back on every way out of this function, after the reader below has let go.
19628    let mut screen = TakenTerminal { restored: false };
19629    // Anything written to stderr from here on would be drawn over the screen; it goes
19630    // to the log until this drops, on every way out of this function.
19631    let session = logging::TuiSession::begin(restore_terminal);
19632    push_keyboard_flags();
19633    // Asked before the settings are read, so the answer is usually in by the time they
19634    // are; under an explicit `theme.mode` it is read and dropped. The reader takes it
19635    // off the input stream, so nothing waits here.
19636    let asked = terminal_color::supported()
19637        && config.as_ref().is_none_or(|c| c.theme.follow)
19638        && terminal_color::ask(&mut std::io::stdout());
19639    let mut background = None;
19640    let (tx, rx) = mpsc::channel::<AppEvent>();
19641    {
19642        let tx = tx.clone();
19643        session.wake_with(move || {
19644            let _ = tx.send(AppEvent::Wake);
19645        });
19646    }
19647    let mut reader = terminal_input::TerminalInput::start(tx.clone())?;
19648    // Only for the datui binary: the handlers stay for the life of the process, and a
19649    // host such as Python keeps its own.
19650    #[cfg(any(unix, windows))]
19651    if matches!(input, RunInput::Cli(_)) {
19652        quit_on_signals(&rt_handle, &tx);
19653    }
19654
19655    // The settings are files, so they are read on a worker while the keys are already
19656    // being read: a slow mount shows a screen saying so, and Ctrl+C or Ctrl+Q leave it.
19657    let waiting_on = startup::named(&input);
19658    {
19659        let tx = tx.clone();
19660        std::thread::Builder::new()
19661            .name("datui-settings".into())
19662            .spawn(move || {
19663                let read = logging::catch_panic(|| startup::read(input, config))
19664                    .unwrap_or_else(|panic| Err(color_eyre::eyre::eyre!(panic)));
19665                let _ = tx.send(AppEvent::SettingsRead(Box::new(read)));
19666            })?;
19667    }
19668    let mut backlog = Vec::new();
19669    let grace_ends = std::time::Instant::now() + startup::GRACE;
19670    let mut waiting_shown = false;
19671    let settings = loop {
19672        let timeout = if waiting_shown {
19673            std::time::Duration::MAX
19674        } else {
19675            grace_ends.saturating_duration_since(std::time::Instant::now())
19676        };
19677        match rx.recv_timeout(timeout) {
19678            Ok(AppEvent::SettingsRead(read)) => break *read,
19679            Ok(AppEvent::TerminalBackground(mode)) => background = Some(mode),
19680            Ok(AppEvent::Terminal(crossterm::event::Event::Key(key)))
19681                if key.modifiers.contains(KeyModifiers::CONTROL)
19682                    && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q')) =>
19683            {
19684                reader.stop();
19685                screen.restore();
19686                return Ok(None);
19687            }
19688            Ok(AppEvent::Crash(msg)) => {
19689                reader.stop();
19690                screen.restore();
19691                return Err(color_eyre::eyre::eyre!(msg));
19692            }
19693            // A signal, before there was an app to quit.
19694            Ok(AppEvent::Exit) => {
19695                reader.stop();
19696                screen.restore();
19697                return Ok(None);
19698            }
19699            Ok(event) => {
19700                if waiting_shown
19701                    && matches!(
19702                        event,
19703                        AppEvent::Terminal(crossterm::event::Event::Resize(..))
19704                    )
19705                {
19706                    terminal
19707                        .get()
19708                        .draw(|frame| startup::draw_waiting(frame, waiting_on.as_deref()))?;
19709                }
19710                // Typed before there was an app to take it: handled, in order, first.
19711                backlog.push(event);
19712            }
19713            Err(_) => {
19714                terminal
19715                    .get()
19716                    .draw(|frame| startup::draw_waiting(frame, waiting_on.as_deref()))?;
19717                let _ = std::io::stdout().flush();
19718                waiting_shown = true;
19719            }
19720        }
19721    };
19722    let startup::Settings {
19723        config,
19724        theme,
19725        input,
19726        opts,
19727        notes,
19728    } = match settings {
19729        Ok(settings) => settings,
19730        Err(e) => {
19731            reader.stop();
19732            screen.restore();
19733            return Err(e);
19734        }
19735    };
19736
19737    // The first frame is not held for the terminal's answer: one that is already in
19738    // is used, else this terminal's last one (see `App::settle_first_palette`).
19739    let background = (asked && config.theme.follow)
19740        .then(|| startup::take_answer(&rx, background, &mut backlog))
19741        .flatten();
19742    if config.theme.follow && terminal_color::supported() {
19743        follow_focus(&mut std::io::stdout());
19744    }
19745
19746    // Choose the glyph alphabet before the first frame: on a terminal that is not
19747    // doing UTF-8, box-drawing characters render as replacement boxes and make the
19748    // UI harder to read rather than prettier.
19749    glyphs::init_with_overrides(config.display.unicode, &config.glyphs.overrides);
19750
19751    // Taken once the settings say so; handed back with the screen.
19752    pointer::capture(config.display.mouse, &mut std::io::stdout());
19753
19754    let mut app = App::new_with_views(tx.clone(), rt_handle, theme, config, views);
19755    app.settle_first_palette(background);
19756    if let Some(out) = passed {
19757        app.pass_stdout_to(out);
19758    }
19759    app.startup_view = opts.view.clone();
19760    // A developer's overlay: an environment variable, not a flag.
19761    let debug_env = std::env::var_os("DATUI_DEBUG").is_some_and(|v| !v.is_empty() && v != "0");
19762    if opts.debug || debug_env {
19763        app.enable_debug();
19764    }
19765
19766    // Show the first frame immediately; the open it announces is handled right after.
19767    let open = match input {
19768        // No paths: open the home screen instead of loading anything.
19769        RunInput::Paths(paths, _) if paths.is_empty() => {
19770            app.enter_home();
19771            None
19772        }
19773        RunInput::Paths(paths, opts) => {
19774            // Whether each path is there, and whether a directory was named, is asked
19775            // after this frame, on a worker; the frame says what is being opened.
19776            app.set_loading_phase("Scanning input", 10);
19777            if let [path] = paths.as_slice() {
19778                app.name_what_is_loading(path.clone());
19779            }
19780            Some(AppEvent::OpenNamed(paths, opts))
19781        }
19782        RunInput::LazyFrame(lf, opts) => {
19783            app.set_loading_phase("Scanning input", 10);
19784            Some(AppEvent::OpenLazyFrame(lf, opts))
19785        }
19786        RunInput::Cli(_) | RunInput::Host(..) => {
19787            unreachable!("read_settings resolves the command line")
19788        }
19789    };
19790    // Declared before the pump, so it drops after it: the app's own files go with the
19791    // app, and this then removes what a worker was still writing.
19792    let _sweep = app.exit_sweep();
19793    let input_tx = tx.clone();
19794    let mut pump = EventPump::new(app, tx, rx);
19795    // The open goes out before the keys typed while the settings were read, so they
19796    // meet it as they would any open in flight: Ctrl+O puts it down, `q` quits. Sent
19797    // on the channel instead, it lost to a Ctrl+O offered ahead of the channel and
19798    // opened behind the home screen, or behind whatever was opened from there.
19799    pump.handle_first(backlog.into_iter().chain(open));
19800    let end = pump.run(|app| {
19801        if let Some(open) = app.take_external_open() {
19802            let mouse = app.mouse_enabled();
19803            let focus = app.follows_terminal() && terminal_color::supported();
19804            let note = open_externally(&open, &mut reader, &input_tx, mouse, focus, terminal.get());
19805            app.external_opened(&open, note);
19806        }
19807        // Between frames, so the question is never written into the middle of one.
19808        if app.take_background_query() && terminal_color::supported() {
19809            terminal_color::ask(&mut std::io::stdout());
19810        }
19811        terminal
19812            .get()
19813            .draw(|frame| frame.render_widget(app, frame.area()))?;
19814        let _ = std::io::stdout().flush();
19815        Ok(())
19816    })?;
19817    let result = conclude(end, &pump.app, capture, &mut reader, &mut screen);
19818    // stderr is the terminal again once the session is over.
19819    drop(session);
19820    // Not `eprintln!`, which panics when a hangup has taken the terminal away.
19821    for note in notes {
19822        let _ = writeln!(std::io::stderr(), "datui: {note}");
19823    }
19824    // Quit with the recording kept going: it goes on until its stream ends, with the
19825    // terminal handed back. A signal now ends the process as it always would.
19826    if let Some((tee, handle)) = pump.app.recording_after_exit() {
19827        let to = if tee.to_stdout() {
19828            format!("passing standard input on to {}", tee.name())
19829        } else {
19830            format!("recording standard input to {}", tee.path.display())
19831        };
19832        let _ = writeln!(
19833            std::io::stderr(),
19834            "datui: {to} until it ends (Ctrl+C stops it)"
19835        );
19836        handle.spool().wait();
19837        let done = if tee.to_stdout() {
19838            "datui: standard input ended".to_string()
19839        } else {
19840            format!("datui: saved {}", tee.path.display())
19841        };
19842        let _ = writeln!(std::io::stderr(), "{done}");
19843    }
19844    result
19845}
19846
19847/// Open a value the inspector wrote: a program that takes the terminal gets it
19848/// (the key reader stopped, the screen and raw mode handed back) until it
19849/// returns; an opener is only started. Says what went wrong, if anything.
19850fn open_externally(
19851    open: &external_open::ExternalOpen,
19852    reader: &mut terminal_input::TerminalInput,
19853    tx: &std::sync::mpsc::Sender<AppEvent>,
19854    mouse: bool,
19855    focus: bool,
19856    terminal: &mut ratatui::DefaultTerminal,
19857) -> Option<String> {
19858    let program = external_open::program_for(open.document, |name| std::env::var(name).ok());
19859    let result = match &program {
19860        external_open::Program::Opener(_) => external_open::run(&program, &open.path),
19861        external_open::Program::Wait(_) => {
19862            reader.stop();
19863            restore_terminal();
19864            let result = external_open::run(&program, &open.path);
19865            let _ = crossterm::terminal::enable_raw_mode();
19866            let _ = crossterm::execute!(
19867                std::io::stdout(),
19868                crossterm::terminal::EnterAlternateScreen,
19869                crossterm::cursor::Hide
19870            );
19871            push_keyboard_flags();
19872            pointer::capture(mouse, &mut std::io::stdout());
19873            if focus {
19874                follow_focus(&mut std::io::stdout());
19875            }
19876            let _ = terminal.clear();
19877            match terminal_input::TerminalInput::start(tx.clone()) {
19878                Ok(started) => *reader = started,
19879                Err(e) => {
19880                    let _ = tx.send(AppEvent::Crash(format!("Could not read keys again: {e}")));
19881                }
19882            }
19883            result
19884        }
19885    };
19886    result.err().map(|e| e.to_string())
19887}
19888
19889/// `text` as a sentence for a flash: its first letter capitalized.
19890fn sentence(text: &str) -> String {
19891    let mut chars = text.chars();
19892    match chars.next() {
19893        Some(first) => first.to_uppercase().chain(chars).collect(),
19894        None => String::new(),
19895    }
19896}