Skip to main content

databricks_tui/
app.rs

1use crate::cli::DatabricksCli;
2use crate::fetchers;
3use crate::shape::{DetailData, Shape, Status};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::{mpsc, oneshot};
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum ThemeMode {
10    Dark,
11    Light,
12    CatppuccinMocha,
13    CatppuccinMacchiato,
14    CatppuccinFrappe,
15    CatppuccinLatte,
16    GruvboxDark,
17    GruvboxLight,
18    Dracula,
19    Nord,
20    TokyoNight,
21    RosePine,
22    Everforest,
23    Kanagawa,
24    SolarizedDark,
25    OneDark,
26}
27
28impl ThemeMode {
29    pub const ALL: &'static [ThemeMode] = &[
30        ThemeMode::Dark,
31        ThemeMode::Light,
32        ThemeMode::CatppuccinMocha,
33        ThemeMode::CatppuccinMacchiato,
34        ThemeMode::CatppuccinFrappe,
35        ThemeMode::CatppuccinLatte,
36        ThemeMode::GruvboxDark,
37        ThemeMode::GruvboxLight,
38        ThemeMode::Dracula,
39        ThemeMode::Nord,
40        ThemeMode::TokyoNight,
41        ThemeMode::RosePine,
42        ThemeMode::Everforest,
43        ThemeMode::Kanagawa,
44        ThemeMode::SolarizedDark,
45        ThemeMode::OneDark,
46    ];
47
48    /// The next theme in the cycle — what `t` steps through.
49    pub fn toggled(self) -> Self {
50        let idx = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
51        Self::ALL[(idx + 1) % Self::ALL.len()]
52    }
53
54    pub fn name(&self) -> &'static str {
55        match self {
56            ThemeMode::Dark => "Dark (terminal colors)",
57            ThemeMode::Light => "Light",
58            ThemeMode::CatppuccinMocha => "Catppuccin Mocha",
59            ThemeMode::CatppuccinMacchiato => "Catppuccin Macchiato",
60            ThemeMode::CatppuccinFrappe => "Catppuccin Frappé",
61            ThemeMode::CatppuccinLatte => "Catppuccin Latte",
62            ThemeMode::GruvboxDark => "Gruvbox Dark",
63            ThemeMode::GruvboxLight => "Gruvbox Light",
64            ThemeMode::Dracula => "Dracula",
65            ThemeMode::Nord => "Nord",
66            ThemeMode::TokyoNight => "Tokyo Night",
67            ThemeMode::RosePine => "Rosé Pine",
68            ThemeMode::Everforest => "Everforest Dark",
69            ThemeMode::Kanagawa => "Kanagawa",
70            ThemeMode::SolarizedDark => "Solarized Dark",
71            ThemeMode::OneDark => "One Dark",
72        }
73    }
74
75    /// Stable id, same kebab-case form the --theme flag accepts.
76    pub fn id(&self) -> &'static str {
77        match self {
78            ThemeMode::Dark => "dark",
79            ThemeMode::Light => "light",
80            ThemeMode::CatppuccinMocha => "catppuccin-mocha",
81            ThemeMode::CatppuccinMacchiato => "catppuccin-macchiato",
82            ThemeMode::CatppuccinFrappe => "catppuccin-frappe",
83            ThemeMode::CatppuccinLatte => "catppuccin-latte",
84            ThemeMode::GruvboxDark => "gruvbox",
85            ThemeMode::GruvboxLight => "gruvbox-light",
86            ThemeMode::Dracula => "dracula",
87            ThemeMode::Nord => "nord",
88            ThemeMode::TokyoNight => "tokyo-night",
89            ThemeMode::RosePine => "rose-pine",
90            ThemeMode::Everforest => "everforest",
91            ThemeMode::Kanagawa => "kanagawa",
92            ThemeMode::SolarizedDark => "solarized-dark",
93            ThemeMode::OneDark => "one-dark",
94        }
95    }
96
97    pub fn from_id(id: &str) -> Option<Self> {
98        Self::ALL.iter().copied().find(|t| t.id() == id)
99    }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum Panel {
104    Clusters,
105    Jobs,
106    Pipelines,
107    Warehouses,
108    Dashboards,
109    Catalog,
110    Secrets,
111}
112
113impl Panel {
114    pub const ALL: &'static [Panel] = &[
115        Panel::Clusters,
116        Panel::Jobs,
117        Panel::Pipelines,
118        Panel::Warehouses,
119        Panel::Dashboards,
120        Panel::Catalog,
121        Panel::Secrets,
122    ];
123
124    pub fn title(&self) -> &'static str {
125        match self {
126            Panel::Clusters => "Compute",
127            Panel::Jobs => "Lakeflow Jobs",
128            Panel::Pipelines => "Lakeflow Pipelines",
129            Panel::Warehouses => "SQL Warehouses",
130            Panel::Dashboards => "AI/BI Dashboards",
131            Panel::Catalog => "Unity Catalog",
132            Panel::Secrets => "Secret Scopes",
133        }
134    }
135
136    pub fn icon(&self) -> &'static str {
137        match self {
138            Panel::Clusters => "⌬",
139            Panel::Jobs => "⟳",
140            Panel::Pipelines => "⋙",
141            Panel::Warehouses => "⌁",
142            Panel::Dashboards => "▦",
143            Panel::Catalog => "⧉",
144            Panel::Secrets => "◈",
145        }
146    }
147
148    /// Stable id used in the config file.
149    pub fn id(&self) -> &'static str {
150        match self {
151            Panel::Clusters => "compute",
152            Panel::Jobs => "jobs",
153            Panel::Pipelines => "pipelines",
154            Panel::Warehouses => "warehouses",
155            Panel::Dashboards => "dashboards",
156            Panel::Catalog => "catalog",
157            Panel::Secrets => "secrets",
158        }
159    }
160
161    /// The databricks CLI command group whose `get <id>` returns item details.
162    pub fn cli_group(&self) -> &'static str {
163        match self {
164            Panel::Clusters => "clusters",
165            Panel::Jobs => "jobs",
166            Panel::Pipelines => "pipelines",
167            Panel::Warehouses => "warehouses",
168            Panel::Dashboards => "lakeview",
169            Panel::Catalog => "tables",
170            Panel::Secrets => "secrets",
171        }
172    }
173}
174
175pub struct Detail {
176    pub panel: Panel,
177    pub name: String,
178    pub id: String,
179    /// Item kind for Unity Catalog leaves (TABLE / VIEW / VOLUME).
180    pub kind: Option<String>,
181    /// Heading of the activity section ("Recent activity", "Access", ...).
182    pub section: &'static str,
183    /// None while the fetch is in flight.
184    pub data: Option<DetailData>,
185    /// Toggles between the formatted summary and the raw JSON.
186    pub show_raw: bool,
187    pub scroll: u16,
188}
189
190/// Full-screen sample-data view for a Unity Catalog table or view.
191pub struct Preview {
192    pub name: String,
193    /// Display name and id of the warehouse running the query.
194    pub warehouse: String,
195    pub warehouse_id: String,
196    /// None while the query runs; then rows or an error.
197    pub data: Option<Result<crate::shape::TableData, String>>,
198    /// Top visible row in the grid; the inspected row in record view.
199    pub scroll: usize,
200    /// First visible column, as an index into the filtered column list.
201    pub col: usize,
202    /// Case-insensitive substring filter over column names — the way
203    /// through a 500-column table.
204    pub filter: String,
205    pub filter_entry: bool,
206    /// Transposed view: one row, fields stacked vertically.
207    pub record: bool,
208    /// Field scroll within the record view.
209    pub rscroll: u16,
210}
211
212impl Preview {
213    /// Indices of columns whose name matches the filter (all when empty).
214    pub fn visible_cols(&self) -> Vec<usize> {
215        let Some(Ok(t)) = &self.data else {
216            return Vec::new();
217        };
218        let q = self.filter.to_lowercase();
219        (0..t.headers.len())
220            .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
221            .collect()
222    }
223}
224
225/// What a confirmed warehouse choice should run.
226enum PickTarget {
227    Preview(String),
228    Cost,
229    /// Spend for one job/pipeline: (kind, id, display name).
230    ItemCost(fetchers::cost::ResourceKind, String, String),
231    Lineage(String),
232    Sql(String),
233}
234
235/// Free-form SQL prompt with results, backed by the preview machinery.
236pub struct SqlConsole {
237    pub input: String,
238    /// Caret position in `input`, counted in characters.
239    pub cursor: usize,
240    /// Display name of the warehouse the last query ran on.
241    pub warehouse: String,
242    pub running: bool,
243    pub data: Option<Result<crate::shape::TableData, String>>,
244    /// The statement that produced `data`.
245    pub last_sql: String,
246    pub scroll: usize,
247    /// First visible result column (shift+←/→ pages wide results).
248    pub col: usize,
249    /// Transposed view: one row, fields stacked vertically.
250    pub record: bool,
251    /// Field scroll within the record view.
252    pub rscroll: u16,
253}
254
255/// Tab-completion popup over the SQL prompt, backed by lazily-cached
256/// Unity Catalog names.
257pub struct SqlComplete {
258    /// Candidates for the segment being completed; empty while loading.
259    pub items: Vec<String>,
260    /// The candidate currently inserted into the prompt.
261    pub index: usize,
262    /// Char offset in the input where the completed segment starts —
263    /// the popup anchors under it.
264    pub seg_start: usize,
265    /// The typed prefix, restored on esc.
266    prefix: String,
267    /// Dotted context before the segment ("" for a bare word).
268    context: String,
269    /// True while names are being fetched from the workspace.
270    pub loading: bool,
271}
272
273/// Completed alongside catalog names when a bare word is typed; also
274/// the word list for prompt syntax highlighting.
275pub(crate) const SQL_KEYWORDS: &[&str] = &[
276    "SELECT",
277    "FROM",
278    "WHERE",
279    "GROUP BY",
280    "ORDER BY",
281    "HAVING",
282    "LIMIT",
283    "JOIN",
284    "LEFT JOIN",
285    "INNER JOIN",
286    "FULL OUTER JOIN",
287    "CROSS JOIN",
288    "ON",
289    "AS",
290    "AND",
291    "OR",
292    "NOT",
293    "IN",
294    "IS",
295    "NULL",
296    "DISTINCT",
297    "COUNT",
298    "SUM",
299    "AVG",
300    "MIN",
301    "MAX",
302    "UNION",
303    "UNION ALL",
304    "INSERT INTO",
305    "VALUES",
306    "UPDATE",
307    "SET",
308    "DELETE",
309    "CREATE",
310    "DROP",
311    "ALTER",
312    "SHOW",
313    "DESCRIBE",
314    "EXPLAIN",
315    "WITH",
316    "CASE",
317    "WHEN",
318    "THEN",
319    "ELSE",
320    "END",
321    "BETWEEN",
322    "LIKE",
323    "CAST",
324    "OVER",
325    "PARTITION BY",
326];
327
328/// The dotted identifier ending at the caret: (char offset where its
329/// last segment starts, context path before the last dot, typed prefix).
330fn token_at_cursor(input: &str, cursor: usize) -> (usize, String, String) {
331    let chars: Vec<char> = input.chars().collect();
332    let cursor = cursor.min(chars.len());
333    let mut start = cursor;
334    while start > 0 && (chars[start - 1].is_alphanumeric() || matches!(chars[start - 1], '_' | '.'))
335    {
336        start -= 1;
337    }
338    let token: String = chars[start..cursor].iter().collect();
339    match token.rfind('.') {
340        Some(dot) => {
341            let context = token[..dot].to_string();
342            let prefix = token[dot + 1..].to_string();
343            (start + context.chars().count() + 1, context, prefix)
344        }
345        None => (start, String::new(), token),
346    }
347}
348
349/// The first table referenced by a FROM clause, when fully qualified —
350/// its columns join the candidates for bare words.
351fn from_table(input: &str) -> Option<String> {
352    let pos = input.to_lowercase().find("from ")?;
353    // get(): lowercasing can shift byte offsets in non-ASCII input.
354    let rest = input.get(pos + 5..)?.trim_start();
355    let table: String = rest
356        .chars()
357        .take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
358        .collect();
359    (table.matches('.').count() == 2 && !table.ends_with('.')).then_some(table)
360}
361
362/// Where console history lives; one statement per line, oldest first.
363fn history_path() -> Option<std::path::PathBuf> {
364    let home = std::env::var_os("HOME")?;
365    Some(
366        std::path::PathBuf::from(home)
367            .join(".config")
368            .join("databricks-tui")
369            .join("history"),
370    )
371}
372
373fn load_history() -> Vec<String> {
374    history_path()
375        .and_then(|p| std::fs::read_to_string(p).ok())
376        .map(|s| {
377            s.lines()
378                .filter(|l| !l.trim().is_empty())
379                .map(str::to_string)
380                .collect()
381        })
382        .unwrap_or_default()
383}
384
385fn save_history(history: &[String]) {
386    let Some(path) = history_path() else {
387        return;
388    };
389    if let Some(dir) = path.parent() {
390        let _ = std::fs::create_dir_all(dir);
391        crate::config::restrict(dir, 0o700);
392    }
393    // Keep the tail; nobody scrolls back 200 queries.
394    let tail: Vec<&str> = history
395        .iter()
396        .rev()
397        .take(200)
398        .rev()
399        .map(String::as_str)
400        .collect();
401    // Queries can hold sensitive literals — owner-only, like shell history.
402    let _ = std::fs::write(&path, tail.join("\n") + "\n");
403    crate::config::restrict(&path, 0o600);
404}
405
406/// True when every char of `needle` appears in `haystack` in order.
407fn subsequence(haystack: &str, needle: &str) -> bool {
408    let mut chars = haystack.chars();
409    needle.chars().all(|n| chars.any(|h| h == n))
410}
411
412/// Byte offset of the `cursor`th character in `input`.
413fn byte_at(input: &str, cursor: usize) -> usize {
414    input
415        .char_indices()
416        .nth(cursor)
417        .map(|(i, _)| i)
418        .unwrap_or(input.len())
419}
420
421/// Parses the run-parameter prompt: `key=value` pairs separated by
422/// commas, whitespace-tolerant. Values may contain `=`; empty values
423/// are allowed; an empty input means "run with the job's defaults".
424fn parse_params(input: &str) -> Result<Vec<(String, String)>, String> {
425    let mut pairs = Vec::new();
426    for seg in input.split(',') {
427        let seg = seg.trim();
428        if seg.is_empty() {
429            continue;
430        }
431        let Some((k, v)) = seg.split_once('=') else {
432            return Err(format!(
433                "✗ “{seg}” isn't key=value — separate parameters with commas"
434            ));
435        };
436        let k = k.trim();
437        if k.is_empty() {
438            return Err(format!("✗ “{seg}” is missing the parameter name"));
439        }
440        pairs.push((k.to_string(), v.trim().to_string()));
441    }
442    Ok(pairs)
443}
444
445/// Overlay for choosing which SQL warehouse runs a query.
446pub struct WhPicker {
447    pub index: usize,
448    target: PickTarget,
449}
450
451/// Full-screen DBU usage view backed by system.billing.usage.
452pub struct CostView {
453    pub warehouse: String,
454    pub data: Option<Result<fetchers::cost::CostData, String>>,
455}
456
457/// Spend over week/month/quarter/year for a single job or pipeline.
458pub struct ItemCostView {
459    pub warehouse: String,
460    pub kind: fetchers::cost::ResourceKind,
461    /// Display name of the job/pipeline, and its id.
462    pub name: String,
463    pub id: String,
464    pub data: Option<Result<fetchers::cost::ResourceCost, String>>,
465}
466
467/// Drill-down into a single job run or pipeline update, layered over
468/// the owning detail view.
469pub struct RunView {
470    /// Panel::Jobs (runs) or Panel::Pipelines (updates).
471    pub panel: Panel,
472    pub owner_name: String,
473    /// Job id or pipeline id the runs belong to.
474    owner_id: String,
475    /// Recent runs newest-first: (run_id, status, age).
476    pub runs: Vec<(String, Status, String)>,
477    /// Which of `runs` is shown.
478    pub idx: usize,
479    pub data: Option<DetailData>,
480    pub show_raw: bool,
481    pub scroll: u16,
482    /// True while the shown run is still executing — drives auto-refresh.
483    pub live: bool,
484    /// Full per-task output/logs, fetched on demand via `o`.
485    pub output: Option<String>,
486    pub show_output: bool,
487    /// Gantt view of per-task execution windows; sticky across h/l so
488    /// runs can be compared.
489    pub show_timeline: bool,
490    /// Dependency-tree view of the run's tasks; mutually exclusive with
491    /// the timeline, sticky across h/l like it.
492    pub show_dag: bool,
493    /// History grid: tasks × recent runs, with duration trends. Fetched
494    /// once per run view (the grid is per-job, not per-run).
495    pub show_grid: bool,
496    pub grid: Option<Result<fetchers::runs::GridData, String>>,
497    fetched_at: Instant,
498}
499
500/// (recent runs, detail of the newest, still-executing flag)
501type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
502
503enum RunUpdate {
504    Opened(Result<RunOpened, String>),
505    Detail(DetailData, bool),
506    /// Full task output plus whether the run is still executing.
507    Output(String, bool),
508}
509
510/// One unhealthy resource, pointing back at its pane and item.
511pub struct Problem {
512    /// Index into `Panel::ALL`; None when the problem is a whole
513    /// workspace being unreachable during a cross-workspace scan.
514    pub panel: Option<usize>,
515    pub name: String,
516    pub status: Status,
517    pub note: String,
518    /// Some(profile) when the problem lives in another workspace.
519    pub profile: Option<String>,
520}
521
522/// Overlay collecting everything currently failing: the loaded panes
523/// immediately, plus every other configured workspace as the scan of
524/// their profiles comes back.
525pub struct Problems {
526    pub items: Vec<Problem>,
527    pub index: usize,
528    /// True while other profiles are still being scanned.
529    pub scanning: bool,
530}
531
532/// Overlay listing jobs by their next scheduled execution, soonest first.
533pub struct Upcoming {
534    pub items: Vec<fetchers::upcoming::UpcomingJob>,
535    pub index: usize,
536    pub loading: bool,
537}
538
539/// A pending destructive/mutating action awaiting a y/n keystroke.
540pub struct Confirm {
541    pub message: String,
542    args: Vec<String>,
543    /// Some((job_id, name)) when the action is a job trigger, enabling
544    /// the `p` = edit-parameters escape hatch.
545    pub params: Option<(String, String)>,
546}
547
548/// The run-with-parameters prompt: a single `key=value, …` line,
549/// prefilled with the job's current parameter defaults.
550pub struct ParamForm {
551    pub job_id: String,
552    pub job: String,
553    pub input: String,
554    /// Caret as a char index into `input`.
555    pub cursor: usize,
556    pub kind: fetchers::jobs::ParamKind,
557    /// True until the prefill fetch lands; typing waits for it.
558    pub loading: bool,
559}
560
561/// A run being watched for completion: bell + flash when it finishes.
562pub struct Watched {
563    pub run_id: String,
564    pub job: String,
565}
566
567/// How often the watch list re-checks its runs.
568const WATCH_INTERVAL: Duration = Duration::from_secs(10);
569
570enum Update {
571    Panel(usize, Result<Shape, String>),
572    Badge(Option<Shape>),
573}
574
575const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
576
577pub struct App {
578    pub focus: Panel,
579    pub theme: ThemeMode,
580    pub zoomed: bool,
581    pub shapes: Vec<Option<Shape>>,
582    pub user_badge: Option<Shape>,
583    pub error: Option<String>,
584    pub refresh_interval: Duration,
585    last_refresh: Instant,
586    pub loading: bool,
587    pub detail: Option<Detail>,
588    pub confirm: Option<Confirm>,
589    pub flash: Option<(String, Instant)>,
590    pub selected: [usize; 7],
591    pub host: Option<String>,
592    /// Available profiles from ~/.databrickscfg and the active one.
593    pub profiles: Vec<String>,
594    pub profile: Option<String>,
595    /// When Some, the workspace picker overlay is open at this index.
596    pub picker: Option<usize>,
597    /// When Some, the problems overlay is open.
598    pub problems: Option<Problems>,
599    pub upcoming: Option<Upcoming>,
600    /// Current position in the Unity Catalog tree: [], [catalog] or [catalog, schema].
601    pub uc_path: Vec<String>,
602    uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
603    pub preview: Option<Preview>,
604    preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
605    pub wh_picker: Option<WhPicker>,
606    /// Session-remembered (id, name) of the warehouse used for previews.
607    pub preview_warehouse: Option<(String, String)>,
608    pub cost: Option<CostView>,
609    #[allow(clippy::type_complexity)]
610    cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
611    pub item_cost: Option<ItemCostView>,
612    #[allow(clippy::type_complexity)]
613    item_cost_rx:
614        Option<oneshot::Receiver<(Result<fetchers::cost::ResourceCost, String>, Option<String>)>>,
615    /// Numeric id of the current workspace, resolved lazily for cost
616    /// scoping and cached for the session.
617    workspace_id: Option<String>,
618    pub sql: Option<SqlConsole>,
619    sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
620    /// Past console statements, oldest first; persisted across sessions.
621    sql_history: Vec<String>,
622    /// Position while cycling history with ↑/↓; None = editing a new line.
623    hist_idx: Option<usize>,
624    /// The unfinished statement stashed when history browsing starts.
625    hist_draft: String,
626    /// Ctrl+R incremental search: (query, nth-newest match shown).
627    pub hist_search: Option<(String, usize)>,
628    pub run_view: Option<RunView>,
629    run_rx: Option<oneshot::Receiver<RunUpdate>>,
630    grid_rx: Option<oneshot::Receiver<Result<fetchers::runs::GridData, String>>>,
631    #[allow(clippy::type_complexity)]
632    upcoming_rx: Option<oneshot::Receiver<Result<Vec<fetchers::upcoming::UpcomingJob>, String>>>,
633    problems_rx: Option<oneshot::Receiver<Vec<fetchers::problems::RemoteProblem>>>,
634    pending: Option<mpsc::UnboundedReceiver<Update>>,
635    detail_rx: Option<oneshot::Receiver<DetailData>>,
636    action_rx: Option<oneshot::Receiver<Result<String, String>>>,
637    host_rx: Option<oneshot::Receiver<Option<String>>>,
638    in_flight: usize,
639    spinner_frame: usize,
640    /// Splash screen deadline; None once dismissed.
641    pub splash_until: Option<Instant>,
642    /// When each pane last received fresh data — drives the title flash.
643    pub updated_at: [Option<Instant>; 7],
644    /// Per-pane search filter; empty string means no filter.
645    pub filters: [String; 7],
646    /// True while the user is typing a filter for the focused pane.
647    pub filter_entry: bool,
648    /// Per-pane favorites-only toggle; session state, not persisted.
649    pub fav_only: [bool; 7],
650    /// Persisted preferences (theme, warehouse per profile).
651    pub config: crate::config::Config,
652    /// (name, is-slow-warning) pairs needing attention per pane at the
653    /// last refresh — None until the pane has loaded once, so the first
654    /// load never alerts.
655    failed_seen: [Option<std::collections::HashSet<(String, bool)>>; 7],
656    /// Ctrl+P fuzzy jump overlay.
657    pub jump: Option<Jump>,
658    /// Canonical pane indices in display order.
659    pub pane_order: Vec<usize>,
660    /// Hidden flag per canonical pane index.
661    pub hidden: [bool; 7],
662    /// When Some, the pane-arrangement overlay is open at this position.
663    pub pane_cfg: Option<usize>,
664    /// True while the `?` help overlay is open.
665    pub help: bool,
666    /// Scroll offset of the help overlay.
667    pub help_scroll: u16,
668    /// Statement id of the in-flight console query, for cancellation.
669    sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
670    /// Tab-completion popup state for the SQL prompt.
671    pub sql_complete: Option<SqlComplete>,
672    /// Unity Catalog names for completion, keyed by dotted path ("" →
673    /// catalogs, "cat" → schemas, …). Filled lazily, kept per workspace.
674    uc_names: std::collections::HashMap<String, Vec<String>>,
675    #[allow(clippy::type_complexity)]
676    uc_names_rx: Option<oneshot::Receiver<(String, Result<Vec<String>, String>)>>,
677    /// Drilled-into secret scope; None = the scopes listing.
678    pub secret_scope: Option<String>,
679    secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
680    /// Create-scope / add-secret input form.
681    pub secret_form: Option<SecretForm>,
682    /// Run-with-parameters prompt (via `p` on a job-run confirm).
683    pub param_form: Option<ParamForm>,
684    #[allow(clippy::type_complexity)]
685    param_rx: Option<
686        oneshot::Receiver<Result<(Vec<(String, String)>, fetchers::jobs::ParamKind), String>>,
687    >,
688    /// Runs watched for completion; they outlive the run view.
689    pub watched: Vec<Watched>,
690    #[allow(clippy::type_complexity)]
691    watch_rx: Option<oneshot::Receiver<Vec<(String, Result<(Status, bool), String>)>>>,
692    /// When the watch list last polled its runs.
693    watch_at: Instant,
694}
695
696/// Ctrl+P overlay: fuzzy-search everything loaded, Enter jumps to it.
697pub struct Jump {
698    pub query: String,
699    pub index: usize,
700}
701
702/// Two-step input: a scope name, or a key then a (masked) value.
703pub struct SecretForm {
704    /// Scope the secret goes into; None = creating a new scope.
705    pub scope: Option<String>,
706    pub key: String,
707    pub value: String,
708    /// 0 = typing the name/key, 1 = typing the value.
709    pub stage: u8,
710}
711
712impl App {
713    pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
714        let mut app = Self {
715            focus: Panel::Clusters,
716            theme,
717            zoomed: false,
718            shapes: vec![None; 7],
719            user_badge: None,
720            error: None,
721            refresh_interval: Duration::from_secs(refresh_secs),
722            last_refresh: Instant::now()
723                .checked_sub(Duration::from_secs(refresh_secs + 1))
724                .unwrap_or(Instant::now()),
725            loading: false,
726            detail: None,
727            confirm: None,
728            flash: None,
729            selected: [0; 7],
730            host: None,
731            profiles: Vec::new(),
732            profile: None,
733            picker: None,
734            problems: None,
735            upcoming: None,
736            uc_path: Vec::new(),
737            uc_rx: None,
738            preview: None,
739            preview_rx: None,
740            wh_picker: None,
741            preview_warehouse: None,
742            cost: None,
743            cost_rx: None,
744            item_cost: None,
745            item_cost_rx: None,
746            workspace_id: None,
747            sql: None,
748            sql_rx: None,
749            sql_history: load_history(),
750            hist_idx: None,
751            hist_draft: String::new(),
752            hist_search: None,
753            run_view: None,
754            run_rx: None,
755            grid_rx: None,
756            upcoming_rx: None,
757            problems_rx: None,
758            pending: None,
759            detail_rx: None,
760            action_rx: None,
761            host_rx: None,
762            in_flight: 0,
763            spinner_frame: 0,
764            splash_until: Some(Instant::now() + Duration::from_millis(1600)),
765            updated_at: [None; 7],
766            filters: Default::default(),
767            filter_entry: false,
768            fav_only: [false; 7],
769            config: crate::config::Config::load(),
770            failed_seen: Default::default(),
771            jump: None,
772            pane_order: (0..7).collect(),
773            hidden: [false; 7],
774            pane_cfg: None,
775            help: false,
776            help_scroll: 0,
777            sql_stmt: None,
778            sql_complete: None,
779            uc_names: Default::default(),
780            uc_names_rx: None,
781            secret_scope: None,
782            secrets_rx: None,
783            secret_form: None,
784            param_form: None,
785            param_rx: None,
786            watched: Vec::new(),
787            watch_rx: None,
788            watch_at: Instant::now(),
789        };
790        app.load_pane_prefs();
791        app
792    }
793
794    /// Applies pane order/visibility from the config file.
795    fn load_pane_prefs(&mut self) {
796        let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
797        let mut order: Vec<usize> = self
798            .config
799            .pane_order
800            .iter()
801            .filter_map(|id| idx_of(id))
802            .collect();
803        for i in 0..7 {
804            if !order.contains(&i) {
805                order.push(i);
806            }
807        }
808        self.pane_order = order;
809        for id in &self.config.hidden_panes {
810            if let Some(i) = idx_of(id) {
811                self.hidden[i] = true;
812            }
813        }
814        self.ensure_focus_visible();
815    }
816
817    fn persist_panes(&mut self) {
818        self.config.pane_order = self
819            .pane_order
820            .iter()
821            .map(|&i| Panel::ALL[i].id().to_string())
822            .collect();
823        self.config.hidden_panes = (0..7)
824            .filter(|&i| self.hidden[i])
825            .map(|i| Panel::ALL[i].id().to_string())
826            .collect();
827        self.config.save();
828    }
829
830    /// Canonical pane indices currently shown, in display order.
831    pub fn visible_panes(&self) -> Vec<usize> {
832        self.pane_order
833            .iter()
834            .copied()
835            .filter(|&i| !self.hidden[i])
836            .collect()
837    }
838
839    /// Moves focus off a hidden pane onto the first visible one.
840    fn ensure_focus_visible(&mut self) {
841        let visible = self.visible_panes();
842        let focus_idx = Panel::ALL
843            .iter()
844            .position(|p| p == &self.focus)
845            .unwrap_or(0);
846        if !visible.contains(&focus_idx) {
847            if let Some(&first) = visible.first() {
848                self.focus = Panel::ALL[first];
849            }
850        }
851    }
852
853    /// Unhides a pane (used when a jump targets it).
854    fn reveal_pane(&mut self, idx: usize) {
855        if self.hidden[idx] {
856            self.hidden[idx] = false;
857            self.persist_panes();
858        }
859    }
860
861    pub fn open_pane_cfg(&mut self) {
862        self.pane_cfg = Some(0);
863    }
864
865    pub fn pane_cfg_next(&mut self) {
866        if let Some(i) = self.pane_cfg {
867            self.pane_cfg = Some((i + 1).min(6));
868        }
869    }
870
871    pub fn pane_cfg_prev(&mut self) {
872        if let Some(i) = self.pane_cfg {
873            self.pane_cfg = Some(i.saturating_sub(1));
874        }
875    }
876
877    /// Space in the overlay: toggles visibility of the selected pane
878    /// (refusing to hide the last visible one).
879    pub fn pane_cfg_toggle(&mut self) {
880        let Some(pos) = self.pane_cfg else {
881            return;
882        };
883        let idx = self.pane_order[pos];
884        if !self.hidden[idx] && self.visible_panes().len() == 1 {
885            self.flash = Some((
886                "✗ at least one pane has to stay visible".to_string(),
887                Instant::now(),
888            ));
889            return;
890        }
891        self.hidden[idx] = !self.hidden[idx];
892        self.ensure_focus_visible();
893        self.persist_panes();
894    }
895
896    /// J/K in the overlay: moves the selected pane down/up in the order.
897    pub fn pane_cfg_move(&mut self, delta: i32) {
898        let Some(pos) = self.pane_cfg else {
899            return;
900        };
901        let new = if delta < 0 {
902            pos.saturating_sub(1)
903        } else {
904            (pos + 1).min(6)
905        };
906        if new != pos {
907            self.pane_order.swap(pos, new);
908            self.pane_cfg = Some(new);
909            self.persist_panes();
910        }
911    }
912
913    /// Flashes (and rings the bell) when a resource fails — or starts
914    /// running suspiciously long — between one refresh and the next.
915    fn alert_new_failures(&mut self, idx: usize) {
916        // Catalog "error rows" are listing problems, not runtime failures.
917        if idx >= 5 {
918            return;
919        }
920        let Some(Shape::List(items)) = &self.shapes[idx] else {
921            return;
922        };
923        // Keyed (name, is_slow) so a slow-run warning doesn't swallow a
924        // later real failure of the same job, or vice versa.
925        let mut attention: std::collections::HashSet<(String, bool)> =
926            std::collections::HashSet::new();
927        for it in items {
928            let failed = matches!(it.status, Status::Failed)
929                || it
930                    .history
931                    .last()
932                    .is_some_and(|s| matches!(s, Status::Failed));
933            if failed {
934                attention.insert((it.name.clone(), false));
935            }
936            if it.alert.is_some() {
937                attention.insert((it.name.clone(), true));
938            }
939        }
940        if let Some(prev) = &self.failed_seen[idx] {
941            let mut newly: Vec<&(String, bool)> = attention.difference(prev).collect();
942            if !newly.is_empty() {
943                // Failures outrank slow-run warnings when both are new.
944                newly.sort_by_key(|(name, slow)| (*slow, name.clone()));
945                let extra = if newly.len() > 1 {
946                    format!(" (+{} more)", newly.len() - 1)
947                } else {
948                    String::new()
949                };
950                let (name, slow) = newly[0];
951                self.flash = Some((
952                    if *slow {
953                        format!("⚠ {name}{extra} running much longer than usual — ! to inspect")
954                    } else {
955                        format!("✗ {name}{extra} just failed — ! to inspect")
956                    },
957                    Instant::now(),
958                ));
959                // Bell so a backgrounded terminal (or tmux) flags it too.
960                print!("\x07");
961                let _ = std::io::Write::flush(&mut std::io::stdout());
962            }
963        }
964        self.failed_seen[idx] = Some(attention);
965    }
966
967    /// Remembers the current theme across sessions.
968    pub fn persist_theme(&mut self) {
969        self.config.theme = Some(self.theme.id().to_string());
970        self.config.save();
971    }
972
973    /// Restores the remembered warehouse for the active profile.
974    pub fn restore_warehouse_pref(&mut self) {
975        let profile = self.profile.as_deref().unwrap_or("DEFAULT");
976        self.preview_warehouse = self.config.warehouses.get(profile).cloned();
977    }
978
979    pub fn splash_active(&self) -> bool {
980        self.splash_until
981            .map(|t| Instant::now() < t)
982            .unwrap_or(false)
983    }
984
985    pub fn dismiss_splash(&mut self) {
986        self.splash_until = None;
987    }
988
989    /// True while any pane's data just landed — keeps the flash decaying.
990    pub fn any_fresh(&self) -> bool {
991        self.updated_at
992            .iter()
993            .flatten()
994            .any(|t| t.elapsed() < Duration::from_millis(1200))
995    }
996
997    pub fn open_picker(&mut self) {
998        if self.profiles.is_empty() {
999            return;
1000        }
1001        let current = self
1002            .profile
1003            .as_deref()
1004            .and_then(|p| self.profiles.iter().position(|n| n == p))
1005            .unwrap_or(0);
1006        self.picker = Some(current);
1007    }
1008
1009    pub fn picker_next(&mut self) {
1010        if let Some(i) = self.picker {
1011            self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
1012        }
1013    }
1014
1015    pub fn picker_prev(&mut self) {
1016        if let Some(i) = self.picker {
1017            self.picker = Some(i.saturating_sub(1));
1018        }
1019    }
1020
1021    /// Confirms the picker selection; returns the new CLI handle to use.
1022    pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
1023        let idx = self.picker.take()?;
1024        let name = self.profiles.get(idx)?.clone();
1025        Some(self.switch_profile(name))
1026    }
1027
1028    /// Switches to `name`, dropping all workspace-specific state, and
1029    /// returns the CLI handle for the new workspace.
1030    fn switch_profile(&mut self, name: String) -> Arc<DatabricksCli> {
1031        let profile_arg = if name == "DEFAULT" {
1032            None
1033        } else {
1034            Some(name.clone())
1035        };
1036        self.profile = Some(name);
1037
1038        // Drop all workspace-specific state; panes go back to loading.
1039        self.shapes = vec![None; 7];
1040        self.user_badge = None;
1041        self.host = None;
1042        self.selected = [0; 7];
1043        self.detail = None;
1044        self.detail_rx = None;
1045        self.confirm = None;
1046        self.problems = None;
1047        self.problems_rx = None;
1048        self.uc_path.clear();
1049        self.uc_rx = None;
1050        self.secret_scope = None;
1051        self.secrets_rx = None;
1052        self.secret_form = None;
1053        self.param_form = None;
1054        self.param_rx = None;
1055        self.watched.clear();
1056        self.watch_rx = None;
1057        self.preview = None;
1058        self.preview_rx = None;
1059        self.wh_picker = None;
1060        self.preview_warehouse = None;
1061        self.cost = None;
1062        self.cost_rx = None;
1063        self.item_cost = None;
1064        self.item_cost_rx = None;
1065        self.workspace_id = None;
1066        self.sql = None;
1067        self.sql_rx = None;
1068        self.run_view = None;
1069        self.run_rx = None;
1070        self.grid_rx = None;
1071        self.pending = None;
1072        self.in_flight = 0;
1073        self.loading = false;
1074        self.zoomed = false;
1075        self.filters = Default::default();
1076        self.filter_entry = false;
1077        self.fav_only = [false; 7];
1078        self.failed_seen = Default::default();
1079        self.jump = None;
1080        self.sql_stmt = None;
1081        self.sql_complete = None;
1082        self.uc_names.clear();
1083        self.uc_names_rx = None;
1084        self.restore_warehouse_pref();
1085
1086        Arc::new(DatabricksCli::new(profile_arg))
1087    }
1088
1089    pub fn open_jump(&mut self) {
1090        self.jump = Some(Jump {
1091            query: String::new(),
1092            index: 0,
1093        });
1094    }
1095
1096    /// Everything loaded that matches the jump query, best first:
1097    /// (panel index, item name, kind/status label). Substring matches
1098    /// rank above in-order subsequence matches.
1099    pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
1100        let Some(jump) = &self.jump else {
1101            return Vec::new();
1102        };
1103        let q = jump.query.to_lowercase();
1104        let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
1105        for (i, shape) in self.shapes.iter().enumerate() {
1106            let Some(Shape::List(items)) = shape else {
1107                continue;
1108            };
1109            for it in items {
1110                let name = it.name.to_lowercase();
1111                let rank = if q.is_empty() || name.contains(&q) {
1112                    0
1113                } else if subsequence(&name, &q) {
1114                    1
1115                } else {
1116                    continue;
1117                };
1118                scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
1119            }
1120        }
1121        scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
1122        scored
1123            .into_iter()
1124            .take(12)
1125            .map(|(_, i, name, label)| (i, name, label))
1126            .collect()
1127    }
1128
1129    pub fn jump_push(&mut self, c: char) {
1130        if let Some(j) = &mut self.jump {
1131            j.query.push(c);
1132            j.index = 0;
1133        }
1134    }
1135
1136    pub fn jump_pop(&mut self) {
1137        if let Some(j) = &mut self.jump {
1138            j.query.pop();
1139            j.index = 0;
1140        }
1141    }
1142
1143    pub fn jump_next(&mut self) {
1144        let len = self.jump_matches().len();
1145        if let Some(j) = &mut self.jump {
1146            j.index = (j.index + 1).min(len.saturating_sub(1));
1147        }
1148    }
1149
1150    pub fn jump_prev(&mut self) {
1151        if let Some(j) = &mut self.jump {
1152            j.index = j.index.saturating_sub(1);
1153        }
1154    }
1155
1156    /// Jumps focus and selection to the highlighted match.
1157    pub fn jump_go(&mut self) {
1158        let matches = self.jump_matches();
1159        let Some(jump) = self.jump.take() else {
1160            return;
1161        };
1162        let Some((panel_idx, name, _)) = matches.get(jump.index) else {
1163            return;
1164        };
1165        self.reveal_pane(*panel_idx);
1166        self.focus = Panel::ALL[*panel_idx];
1167        self.filters[*panel_idx].clear();
1168        self.fav_only[*panel_idx] = false;
1169        if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
1170            if let Some(pos) = items.iter().position(|i| &i.name == name) {
1171                self.selected[*panel_idx] = pos;
1172            }
1173        }
1174    }
1175
1176    /// Collects everything unhealthy across the loaded panes — items
1177    /// whose status is failed, or whose most recent run failed — then
1178    /// scans every other configured workspace in the background.
1179    pub fn open_problems(&mut self) {
1180        let mut items = Vec::new();
1181        for (i, shape) in self.shapes.iter().enumerate() {
1182            let Some(Shape::List(list)) = shape else {
1183                continue;
1184            };
1185            for it in list {
1186                let failed_now = matches!(it.status, Status::Failed);
1187                let failed_last = it
1188                    .history
1189                    .last()
1190                    .is_some_and(|s| matches!(s, Status::Failed));
1191                if failed_now || failed_last || it.alert.is_some() {
1192                    let note = if failed_now {
1193                        it.detail.clone().unwrap_or_default()
1194                    } else if let Some(alert) = &it.alert {
1195                        alert.clone()
1196                    } else {
1197                        "latest run failed".to_string()
1198                    };
1199                    items.push(Problem {
1200                        panel: Some(i),
1201                        name: it.name.clone(),
1202                        status: it.status.clone(),
1203                        note,
1204                        profile: None,
1205                    });
1206                }
1207            }
1208        }
1209        let current = self
1210            .profile
1211            .clone()
1212            .unwrap_or_else(|| "DEFAULT".to_string());
1213        let others: Vec<String> = self
1214            .profiles
1215            .iter()
1216            .filter(|n| **n != current)
1217            .cloned()
1218            .collect();
1219        let scanning = !others.is_empty();
1220        if scanning {
1221            let (tx, rx) = oneshot::channel();
1222            self.problems_rx = Some(rx);
1223            tokio::spawn(async move {
1224                let _ = tx.send(fetchers::problems::fetch(others, current).await);
1225            });
1226        }
1227        self.problems = Some(Problems {
1228            items,
1229            index: 0,
1230            scanning,
1231        });
1232    }
1233
1234    pub fn close_problems(&mut self) {
1235        self.problems = None;
1236        self.problems_rx = None;
1237    }
1238
1239    pub fn poll_problems(&mut self) -> bool {
1240        let Some(rx) = &mut self.problems_rx else {
1241            return false;
1242        };
1243        match rx.try_recv() {
1244            Ok(remote) => {
1245                self.problems_rx = None;
1246                let Some(pr) = &mut self.problems else {
1247                    return false;
1248                };
1249                pr.scanning = false;
1250                pr.items.extend(remote.into_iter().map(|r| Problem {
1251                    panel: r.panel,
1252                    name: r.name,
1253                    status: r.status,
1254                    note: r.note,
1255                    profile: Some(r.profile),
1256                }));
1257                true
1258            }
1259            Err(oneshot::error::TryRecvError::Empty) => false,
1260            Err(oneshot::error::TryRecvError::Closed) => {
1261                self.problems_rx = None;
1262                if let Some(pr) = &mut self.problems {
1263                    pr.scanning = false;
1264                }
1265                true
1266            }
1267        }
1268    }
1269
1270    pub fn problems_next(&mut self) {
1271        if let Some(pr) = &mut self.problems {
1272            pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
1273        }
1274    }
1275
1276    pub fn problems_prev(&mut self) {
1277        if let Some(pr) = &mut self.problems {
1278            pr.index = pr.index.saturating_sub(1);
1279        }
1280    }
1281
1282    /// Jumps focus and selection to the highlighted problem's pane item.
1283    /// A problem in another workspace switches to that workspace instead;
1284    /// the returned CLI handle must then replace the current one.
1285    pub fn problems_jump(&mut self) -> Option<Arc<DatabricksCli>> {
1286        let Some(pr) = self.problems.take() else {
1287            self.problems_rx = None;
1288            return None;
1289        };
1290        self.problems_rx = None;
1291        let problem = pr.items.get(pr.index)?;
1292        if let Some(profile) = &problem.profile {
1293            let target = match problem.panel {
1294                Some(i) => format!("{} is in {}", problem.name, Panel::ALL[i].title()),
1295                None => "check its auth".to_string(),
1296            };
1297            self.flash = Some((
1298                format!("⌂ switched to {profile} — {target}"),
1299                Instant::now(),
1300            ));
1301            let profile = profile.clone();
1302            return Some(self.switch_profile(profile));
1303        }
1304        let panel = problem.panel?;
1305        self.reveal_pane(panel);
1306        self.focus = Panel::ALL[panel];
1307        // The pane's filter (or favorites-only) could hide the item we're
1308        // jumping to.
1309        self.filters[panel].clear();
1310        self.fav_only[panel] = false;
1311        if let Some(Shape::List(list)) = &self.shapes[panel] {
1312            if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
1313                self.selected[panel] = pos;
1314            }
1315        }
1316        None
1317    }
1318
1319    /// `u`: what runs next — every job with a schedule or trigger,
1320    /// soonest first, fetched fresh so the countdowns are current.
1321    pub fn open_upcoming(&mut self, cli: &Arc<DatabricksCli>) {
1322        self.upcoming = Some(Upcoming {
1323            items: Vec::new(),
1324            index: 0,
1325            loading: true,
1326        });
1327        let (tx, rx) = oneshot::channel();
1328        self.upcoming_rx = Some(rx);
1329        let cli = Arc::clone(cli);
1330        tokio::spawn(async move {
1331            let _ = tx.send(fetchers::upcoming::fetch(&cli).await);
1332        });
1333    }
1334
1335    pub fn close_upcoming(&mut self) {
1336        self.upcoming = None;
1337        self.upcoming_rx = None;
1338    }
1339
1340    pub fn poll_upcoming(&mut self) -> bool {
1341        let Some(rx) = &mut self.upcoming_rx else {
1342            return false;
1343        };
1344        match rx.try_recv() {
1345            Ok(result) => {
1346                self.upcoming_rx = None;
1347                match result {
1348                    Ok(items) => {
1349                        if let Some(u) = &mut self.upcoming {
1350                            u.items = items;
1351                            u.loading = false;
1352                        }
1353                    }
1354                    Err(e) => {
1355                        self.upcoming = None;
1356                        let first = e.lines().next().unwrap_or("failed").to_string();
1357                        self.flash = Some((format!("✗ upcoming: {first}"), Instant::now()));
1358                    }
1359                }
1360                true
1361            }
1362            Err(oneshot::error::TryRecvError::Empty) => false,
1363            Err(oneshot::error::TryRecvError::Closed) => {
1364                self.upcoming_rx = None;
1365                true
1366            }
1367        }
1368    }
1369
1370    pub fn upcoming_next(&mut self) {
1371        if let Some(u) = &mut self.upcoming {
1372            u.index = (u.index + 1).min(u.items.len().saturating_sub(1));
1373        }
1374    }
1375
1376    pub fn upcoming_prev(&mut self) {
1377        if let Some(u) = &mut self.upcoming {
1378            u.index = u.index.saturating_sub(1);
1379        }
1380    }
1381
1382    /// Jumps focus and selection to the highlighted job in the Jobs pane.
1383    pub fn upcoming_jump(&mut self) {
1384        let Some(u) = self.upcoming.take() else {
1385            return;
1386        };
1387        self.upcoming_rx = None;
1388        let Some(item) = u.items.get(u.index) else {
1389            return;
1390        };
1391        let Some(idx) = Panel::ALL.iter().position(|p| *p == Panel::Jobs) else {
1392            return;
1393        };
1394        self.reveal_pane(idx);
1395        self.focus = Panel::Jobs;
1396        self.filters[idx].clear();
1397        if let Some(Shape::List(list)) = &self.shapes[idx] {
1398            if let Some(pos) = list.iter().position(|i| i.name == item.name) {
1399                self.selected[idx] = pos;
1400            }
1401        }
1402    }
1403
1404    /// Resolves the workspace host in the background — `auth describe` can
1405    /// take seconds when it refreshes tokens, so it must not block the loop.
1406    pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
1407        let (tx, rx) = oneshot::channel();
1408        self.host_rx = Some(rx);
1409        let cli = Arc::clone(cli);
1410        tokio::spawn(async move {
1411            let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
1412                json["details"]["host"]
1413                    .as_str()
1414                    .or_else(|| json["host"].as_str())
1415                    .map(str::to_string)
1416            });
1417            let _ = tx.send(host);
1418        });
1419    }
1420
1421    pub fn poll_host(&mut self) {
1422        if let Some(rx) = &mut self.host_rx {
1423            match rx.try_recv() {
1424                Ok(host) => {
1425                    self.host = host;
1426                    self.host_rx = None;
1427                }
1428                Err(oneshot::error::TryRecvError::Empty) => {}
1429                Err(oneshot::error::TryRecvError::Closed) => {
1430                    self.host_rx = None;
1431                }
1432            }
1433        }
1434    }
1435
1436    fn focus_index(&self) -> usize {
1437        Panel::ALL
1438            .iter()
1439            .position(|p| p == &self.focus)
1440            .unwrap_or(0)
1441    }
1442
1443    /// The favorite keys stored for a pane under the active profile, if any.
1444    fn favorite_keys(&self, idx: usize) -> Option<&Vec<String>> {
1445        let profile = self.profile.as_deref().unwrap_or("DEFAULT");
1446        self.config
1447            .favorites
1448            .get(profile)
1449            .and_then(|panes| panes.get(Panel::ALL[idx].id()))
1450    }
1451
1452    /// Whether an item is favorited in its pane.
1453    pub fn is_favorite(&self, idx: usize, item: &crate::shape::ListItem) -> bool {
1454        let key = crate::shape::fav_key(item);
1455        self.favorite_keys(idx)
1456            .is_some_and(|keys| keys.iter().any(|k| k == &key))
1457    }
1458
1459    /// Favorite keys for a pane as a set, for O(1) lookups while rendering.
1460    pub fn pane_fav_set(&self, idx: usize) -> std::collections::HashSet<String> {
1461        self.favorite_keys(idx)
1462            .map(|keys| keys.iter().cloned().collect())
1463            .unwrap_or_default()
1464    }
1465
1466    /// Whether favorites-only is on AND the pane has at least one favorite at
1467    /// the current level — so drilling into the catalog never dead-ends.
1468    pub fn fav_filter_active(&self, idx: usize) -> bool {
1469        self.fav_only[idx]
1470            && matches!(&self.shapes[idx], Some(Shape::List(items))
1471                if items.iter().any(|it| self.is_favorite(idx, it)))
1472    }
1473
1474    /// The predicate deciding whether an item shows in a pane: text filter
1475    /// plus the favorites-only filter when active.
1476    fn passes(&self, idx: usize, item: &crate::shape::ListItem) -> bool {
1477        crate::shape::item_matches(item, &self.filters[idx])
1478            && (!self.fav_filter_active(idx) || self.is_favorite(idx, item))
1479    }
1480
1481    fn list_len(&self, idx: usize) -> usize {
1482        match &self.shapes[idx] {
1483            Some(Shape::List(items)) => items.iter().filter(|it| self.passes(idx, it)).count(),
1484            _ => 0,
1485        }
1486    }
1487
1488    /// Selection index for a panel, clamped to the current list length.
1489    pub fn selection(&self, idx: usize) -> usize {
1490        self.selected[idx].min(self.list_len(idx).saturating_sub(1))
1491    }
1492
1493    pub fn select_next(&mut self) {
1494        let idx = self.focus_index();
1495        let len = self.list_len(idx);
1496        if len > 0 {
1497            self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
1498        }
1499    }
1500
1501    pub fn select_prev(&mut self) {
1502        let idx = self.focus_index();
1503        self.selected[idx] = self.selection(idx).saturating_sub(1);
1504    }
1505
1506    /// The currently highlighted item in the focused panel, respecting
1507    /// the pane's filter — the nth *visible* item, like the UI shows.
1508    fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1509        let idx = self.focus_index();
1510        match &self.shapes[idx] {
1511            Some(Shape::List(items)) => items
1512                .iter()
1513                .filter(|it| self.passes(idx, it))
1514                .nth(self.selection(idx)),
1515            _ => None,
1516        }
1517    }
1518
1519    /// Pins/unpins the selected item in the focused pane; persisted at once.
1520    pub fn toggle_favorite(&mut self) {
1521        let idx = self.focus_index();
1522        let Some((key, name)) = self
1523            .selected_item()
1524            .map(|it| (crate::shape::fav_key(it), it.name.clone()))
1525        else {
1526            return;
1527        };
1528        let pane_id = Panel::ALL[idx].id().to_string();
1529        let profile = self.profile.as_deref().unwrap_or("DEFAULT").to_string();
1530        let list = self
1531            .config
1532            .favorites
1533            .entry(profile)
1534            .or_default()
1535            .entry(pane_id)
1536            .or_default();
1537        let pinned = match list.iter().position(|k| k == &key) {
1538            Some(pos) => {
1539                list.remove(pos);
1540                false
1541            }
1542            None => {
1543                list.push(key);
1544                true
1545            }
1546        };
1547        self.config.save();
1548        let msg = if pinned {
1549            format!("★ pinned {name}")
1550        } else {
1551            format!("☆ unpinned {name}")
1552        };
1553        self.flash = Some((msg, Instant::now()));
1554    }
1555
1556    /// Toggles favorites-only for the focused pane.
1557    pub fn toggle_fav_only(&mut self) {
1558        let idx = self.focus_index();
1559        self.fav_only[idx] = !self.fav_only[idx];
1560        self.selected[idx] = 0;
1561        let has_fav = self.favorite_keys(idx).is_some_and(|k| !k.is_empty());
1562        let msg = if !self.fav_only[idx] {
1563            "showing all".to_string()
1564        } else if has_fav {
1565            "★ favorites only".to_string()
1566        } else {
1567            "★ favorites only — none pinned yet (press f)".to_string()
1568        };
1569        self.flash = Some((msg, Instant::now()));
1570    }
1571
1572    /// Opens filter entry for the focused pane, starting from scratch.
1573    pub fn filter_start(&mut self) {
1574        let idx = self.focus_index();
1575        self.filters[idx].clear();
1576        self.selected[idx] = 0;
1577        self.filter_entry = true;
1578    }
1579
1580    pub fn filter_push(&mut self, c: char) {
1581        let idx = self.focus_index();
1582        self.filters[idx].push(c);
1583        self.selected[idx] = 0;
1584    }
1585
1586    pub fn filter_pop(&mut self) {
1587        let idx = self.focus_index();
1588        self.filters[idx].pop();
1589        self.selected[idx] = 0;
1590    }
1591
1592    /// Keeps the filter applied and returns keys to normal navigation.
1593    pub fn filter_accept(&mut self) {
1594        self.filter_entry = false;
1595    }
1596
1597    pub fn filter_clear(&mut self) {
1598        let idx = self.focus_index();
1599        self.filters[idx].clear();
1600        self.selected[idx] = 0;
1601        self.filter_entry = false;
1602    }
1603
1604    /// The focused pane's filter, if any.
1605    pub fn active_filter(&self) -> &str {
1606        &self.filters[self.focus_index()]
1607    }
1608
1609    pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1610        let Some(item) = self.selected_item() else {
1611            return;
1612        };
1613        let Some(id) = item.id.clone() else {
1614            return;
1615        };
1616        let kind = match &item.status {
1617            Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1618            _ => None,
1619        };
1620        let section = match self.focus {
1621            Panel::Dashboards => "Contents",
1622            Panel::Catalog => "Columns",
1623            Panel::Warehouses => "Recent queries",
1624            _ => "Recent activity",
1625        };
1626        self.detail = Some(Detail {
1627            panel: self.focus,
1628            name: item.name.clone(),
1629            id: id.clone(),
1630            kind,
1631            section,
1632            data: None,
1633            show_raw: false,
1634            scroll: 0,
1635        });
1636
1637        let (tx, rx) = oneshot::channel();
1638        self.detail_rx = Some(rx);
1639        let cli = Arc::clone(cli);
1640        let kind = self.detail.as_ref().unwrap().kind.clone();
1641        // Files in volumes get a content peek instead of an API `get`.
1642        if kind.as_deref() == Some("FILE") {
1643            if let Some(d) = &mut self.detail {
1644                d.section = "File head";
1645            }
1646            tokio::spawn(async move {
1647                let data = fetchers::catalog::file_peek(&cli, &id).await;
1648                let _ = tx.send(data);
1649            });
1650            return;
1651        }
1652        let group = match &kind {
1653            Some(k) if k == "VOLUME" => "volumes",
1654            _ => self.focus.cli_group(),
1655        };
1656        // Tables get extra facts from DESCRIBE DETAIL when a warehouse
1657        // is already remembered — free depth, no picker interruption.
1658        let warehouse = match &kind {
1659            Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1660            _ => None,
1661        };
1662        tokio::spawn(async move {
1663            let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1664            let _ = tx.send(data);
1665        });
1666    }
1667
1668    /// Descends one level in the Unity Catalog tree. Returns false when the
1669    /// selection is a leaf (caller should open the detail view instead).
1670    pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1671        if self.focus != Panel::Catalog {
1672            return false;
1673        }
1674        let Some(item) = self.selected_item() else {
1675            return self.uc_path.is_empty(); // empty root pane: swallow the key
1676        };
1677        // Below the schema level only volumes and their directories are
1678        // containers; tables/views fall through to the detail view.
1679        if self.uc_path.len() >= 2 {
1680            let drillable =
1681                matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1682            if !drillable {
1683                return false;
1684            }
1685        }
1686        self.uc_path.push(item.name.clone());
1687        self.refresh_catalog(cli);
1688        true
1689    }
1690
1691    /// Ascends one level; returns false if already at the catalog root.
1692    pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1693        if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1694            return false;
1695        }
1696        self.uc_path.pop();
1697        self.refresh_catalog(cli);
1698        true
1699    }
1700
1701    fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1702        self.shapes[5] = None;
1703        self.selected[5] = 0;
1704        // A filter typed at one level would silently hide the next.
1705        self.filters[5].clear();
1706        let (tx, rx) = oneshot::channel();
1707        self.uc_rx = Some(rx);
1708        let cli = Arc::clone(cli);
1709        let path = self.uc_path.clone();
1710        tokio::spawn(async move {
1711            let result = fetchers::catalog::fetch(&cli, &path)
1712                .await
1713                .map_err(|e| format!("{e:#}"));
1714            let _ = tx.send(result);
1715        });
1716    }
1717
1718    /// Enter in the Secrets pane: descend from a scope into its keys.
1719    /// On a key it does nothing — secret values are never displayed —
1720    /// but still returns true so Enter never opens a (bogus) detail view.
1721    pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1722        if self.focus != Panel::Secrets {
1723            return false;
1724        }
1725        // Already inside a scope: the selection is a key, nothing to open.
1726        if self.secret_scope.is_some() {
1727            return true;
1728        }
1729        let Some(item) = self.selected_item() else {
1730            return true; // empty pane: swallow the key
1731        };
1732        self.secret_scope = Some(item.name.clone());
1733        self.refresh_secrets(cli);
1734        true
1735    }
1736
1737    /// Backspace inside a scope: back to the scopes listing.
1738    pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1739        if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1740            return false;
1741        }
1742        self.secret_scope = None;
1743        self.refresh_secrets(cli);
1744        true
1745    }
1746
1747    fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1748        let idx = 6;
1749        self.shapes[idx] = None;
1750        self.selected[idx] = 0;
1751        self.filters[idx].clear();
1752        let (tx, rx) = oneshot::channel();
1753        self.secrets_rx = Some(rx);
1754        let cli = Arc::clone(cli);
1755        let scope = self.secret_scope.clone();
1756        tokio::spawn(async move {
1757            let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1758                .await
1759                .map_err(|e| format!("{e:#}"));
1760            let _ = tx.send(result);
1761        });
1762    }
1763
1764    pub fn poll_secrets(&mut self) -> bool {
1765        let Some(rx) = &mut self.secrets_rx else {
1766            return false;
1767        };
1768        match rx.try_recv() {
1769            Ok(result) => {
1770                self.shapes[6] = Some(match result {
1771                    Ok(shape) => shape,
1772                    Err(e) => Shape::Text(format!("✗ {e}")),
1773                });
1774                self.updated_at[6] = Some(Instant::now());
1775                self.secrets_rx = None;
1776                true
1777            }
1778            Err(oneshot::error::TryRecvError::Empty) => false,
1779            Err(oneshot::error::TryRecvError::Closed) => {
1780                self.secrets_rx = None;
1781                true
1782            }
1783        }
1784    }
1785
1786    /// `a` in the secrets pane: create a scope (top level) or add a
1787    /// secret (inside a scope).
1788    pub fn open_secret_form(&mut self) {
1789        if self.focus != Panel::Secrets {
1790            return;
1791        }
1792        self.secret_form = Some(SecretForm {
1793            scope: self.secret_scope.clone(),
1794            key: String::new(),
1795            value: String::new(),
1796            stage: 0,
1797        });
1798    }
1799
1800    pub fn secret_form_push(&mut self, c: char) {
1801        if let Some(form) = &mut self.secret_form {
1802            if form.stage == 0 {
1803                form.key.push(c);
1804            } else {
1805                form.value.push(c);
1806            }
1807        }
1808    }
1809
1810    pub fn secret_form_pop(&mut self) {
1811        if let Some(form) = &mut self.secret_form {
1812            if form.stage == 0 {
1813                form.key.pop();
1814            } else {
1815                form.value.pop();
1816            }
1817        }
1818    }
1819
1820    /// Enter in the form: advance to the value stage, or submit.
1821    pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1822        let Some(form) = &mut self.secret_form else {
1823            return;
1824        };
1825        if form.key.trim().is_empty() {
1826            return;
1827        }
1828        match (form.scope.clone(), form.stage) {
1829            // New scope: single field.
1830            (None, _) => {
1831                let name = form.key.trim().to_string();
1832                self.secret_form = None;
1833                self.run_secret_action(
1834                    cli,
1835                    format!("Create scope “{name}”"),
1836                    vec!["secrets".into(), "create-scope".into(), name],
1837                );
1838            }
1839            // Secret: key first, then value.
1840            (Some(_), 0) => form.stage = 1,
1841            (Some(scope), _) => {
1842                let key = form.key.trim().to_string();
1843                let value = form.value.clone();
1844                self.secret_form = None;
1845                self.run_secret_action(
1846                    cli,
1847                    format!("Put secret “{key}” in “{scope}”"),
1848                    vec![
1849                        "secrets".into(),
1850                        "put-secret".into(),
1851                        scope,
1852                        key,
1853                        "--string-value".into(),
1854                        value,
1855                    ],
1856                );
1857            }
1858        }
1859    }
1860
1861    /// Runs a secrets mutation right away (the form itself was the
1862    /// deliberate step); the usual action poll refreshes the panes.
1863    fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1864        self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1865        let (tx, rx) = oneshot::channel();
1866        self.action_rx = Some(rx);
1867        let cli = Arc::clone(cli);
1868        tokio::spawn(async move {
1869            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1870            let result = match cli.run_action(&arg_refs).await {
1871                Ok(()) => Ok(format!("✓ {label} — done")),
1872                Err(e) => Err(format!("✗ {e:#}")),
1873            };
1874            let _ = tx.send(result);
1875        });
1876    }
1877
1878    /// `x` in the secrets pane: delete the selected scope or key.
1879    pub fn request_secret_delete(&mut self) {
1880        if self.focus != Panel::Secrets {
1881            return;
1882        }
1883        let Some(item) = self.selected_item() else {
1884            return;
1885        };
1886        let name = item.name.clone();
1887        let (message, args) = match &self.secret_scope {
1888            None => (
1889                format!("Delete scope “{name}” and all its secrets?"),
1890                vec!["secrets".to_string(), "delete-scope".to_string(), name],
1891            ),
1892            Some(scope) => (
1893                format!("Delete secret “{name}” from “{scope}”?"),
1894                vec![
1895                    "secrets".to_string(),
1896                    "delete-secret".to_string(),
1897                    scope.clone(),
1898                    name,
1899                ],
1900            ),
1901        };
1902        self.confirm = Some(Confirm {
1903            message,
1904            args,
1905            params: None,
1906        });
1907    }
1908
1909    /// `g` in the secrets pane: the scope's ACLs.
1910    fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1911        let scope = match &self.secret_scope {
1912            Some(s) => Some(s.clone()),
1913            None => self.selected_item().map(|i| i.name.clone()),
1914        };
1915        let Some(scope) = scope else {
1916            return;
1917        };
1918        self.detail = Some(Detail {
1919            panel: Panel::Secrets,
1920            name: scope.clone(),
1921            id: scope.clone(),
1922            kind: None,
1923            section: "Access",
1924            data: None,
1925            show_raw: false,
1926            scroll: 0,
1927        });
1928        let (tx, rx) = oneshot::channel();
1929        self.detail_rx = Some(rx);
1930        let cli = Arc::clone(cli);
1931        tokio::spawn(async move {
1932            let acl_args = ["secrets", "list-acls", &scope];
1933            let data = match cli.run(&acl_args).await {
1934                Ok(json) => {
1935                    let raw =
1936                        serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1937                    // The CLI unwraps to a bare array; REST wraps in "items".
1938                    let acls = json
1939                        .as_array()
1940                        .cloned()
1941                        .or_else(|| json["items"].as_array().cloned())
1942                        .unwrap_or_default();
1943                    let activity: Vec<(Status, String)> = acls
1944                        .iter()
1945                        .map(|a| {
1946                            let principal = a["principal"].as_str().unwrap_or("?");
1947                            let perm = a["permission"].as_str().unwrap_or("?");
1948                            let status = if perm == "MANAGE" {
1949                                Status::Success
1950                            } else {
1951                                Status::Unknown(String::new())
1952                            };
1953                            (status, format!("{principal}  ·  {perm}"))
1954                        })
1955                        .collect();
1956                    DetailData {
1957                        summary: vec![("Scope".to_string(), scope.clone())],
1958                        activity,
1959                        raw,
1960                    }
1961                }
1962                Err(e) => DetailData {
1963                    summary: Vec::new(),
1964                    activity: Vec::new(),
1965                    raw: format!("{e:#}"),
1966                },
1967            };
1968            let _ = tx.send(data);
1969        });
1970    }
1971
1972    /// Looks up a resource's display name by id in the loaded panes —
1973    /// lets the cost view show "nightly-etl" instead of a job id.
1974    pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1975        let idx = match kind {
1976            "cluster" => 0,
1977            "job" => 1,
1978            "warehouse" => 3,
1979            _ => return None,
1980        };
1981        match &self.shapes[idx] {
1982            Some(Shape::List(items)) => items
1983                .iter()
1984                .find(|i| i.id.as_deref() == Some(id))
1985                .map(|i| i.name.clone()),
1986            _ => None,
1987        }
1988    }
1989
1990    /// All known warehouses as (name, id, running).
1991    pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1992        let Some(Shape::List(items)) = &self.shapes[3] else {
1993            return Vec::new();
1994        };
1995        items
1996            .iter()
1997            .filter_map(|i| {
1998                let id = i.id.clone()?;
1999                Some((i.name.clone(), id, matches!(i.status, Status::Running)))
2000            })
2001            .collect()
2002    }
2003
2004    /// Runs a sample-data query for the selected table or view. With
2005    /// `force_pick` (or several warehouses and no remembered choice) a
2006    /// warehouse picker opens first.
2007    pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
2008        if self.focus != Panel::Catalog {
2009            return;
2010        }
2011        let Some(item) = self.selected_item() else {
2012            return;
2013        };
2014        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2015            return;
2016        }
2017        let Some(full_name) = item.id.clone() else {
2018            return;
2019        };
2020        let warehouses = self.warehouses();
2021        if warehouses.is_empty() {
2022            self.flash = Some((
2023                "✗ no SQL warehouse available for previews".to_string(),
2024                Instant::now(),
2025            ));
2026            return;
2027        }
2028        if !force_pick {
2029            if let Some((id, name)) = self.preview_warehouse.clone() {
2030                self.start_preview_query(cli, full_name, id, name);
2031                return;
2032            }
2033            if let [(name, id, _)] = warehouses.as_slice() {
2034                self.preview_warehouse = Some((id.clone(), name.clone()));
2035                self.start_preview_query(cli, full_name, id.clone(), name.clone());
2036                return;
2037            }
2038        }
2039        // Default the cursor to the remembered choice, else a running warehouse.
2040        let index = self
2041            .preview_warehouse
2042            .as_ref()
2043            .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
2044            .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
2045            .unwrap_or(0);
2046        self.wh_picker = Some(WhPicker {
2047            index,
2048            target: PickTarget::Preview(full_name),
2049        });
2050    }
2051
2052    /// Opens the DBU usage view, resolving a warehouse like previews do.
2053    pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
2054        let warehouses = self.warehouses();
2055        if warehouses.is_empty() {
2056            self.flash = Some((
2057                "✗ no SQL warehouse available to query system tables".to_string(),
2058                Instant::now(),
2059            ));
2060            return;
2061        }
2062        if let Some((id, name)) = self.preview_warehouse.clone() {
2063            self.start_cost_query(cli, id, name);
2064            return;
2065        }
2066        if let [(name, id, _)] = warehouses.as_slice() {
2067            self.preview_warehouse = Some((id.clone(), name.clone()));
2068            self.start_cost_query(cli, id.clone(), name.clone());
2069            return;
2070        }
2071        let index = warehouses
2072            .iter()
2073            .position(|(_, _, running)| *running)
2074            .unwrap_or(0);
2075        self.wh_picker = Some(WhPicker {
2076            index,
2077            target: PickTarget::Cost,
2078        });
2079    }
2080
2081    fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
2082        self.cost = Some(CostView {
2083            warehouse: name,
2084            data: None,
2085        });
2086        let (tx, rx) = oneshot::channel();
2087        self.cost_rx = Some(rx);
2088        let cli = Arc::clone(cli);
2089        let host = self.host.clone();
2090        let cached_ws = self.workspace_id.clone();
2091        tokio::spawn(async move {
2092            // Scope usage to this workspace; resolved once, then cached.
2093            let ws = match (cached_ws, host) {
2094                (Some(w), _) => Some(w),
2095                (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
2096                (None, None) => None,
2097            };
2098            let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
2099            let _ = tx.send((result, ws));
2100        });
2101    }
2102
2103    /// Opens the spend view for the selected job or pipeline, resolving
2104    /// a warehouse like the workspace-wide cost view does. A no-op in
2105    /// the other panes — only jobs and pipelines are attributed in
2106    /// system.billing.usage.
2107    pub fn open_item_cost(&mut self, cli: &Arc<DatabricksCli>) {
2108        let kind = match self.focus {
2109            Panel::Jobs => fetchers::cost::ResourceKind::Job,
2110            Panel::Pipelines => fetchers::cost::ResourceKind::Pipeline,
2111            _ => return,
2112        };
2113        let Some(item) = self.selected_item() else {
2114            return;
2115        };
2116        let (Some(id), name) = (item.id.clone(), item.name.clone()) else {
2117            return;
2118        };
2119        let warehouses = self.warehouses();
2120        if warehouses.is_empty() {
2121            self.flash = Some((
2122                "✗ no SQL warehouse available to query system tables".to_string(),
2123                Instant::now(),
2124            ));
2125            return;
2126        }
2127        if let Some((wh_id, wh_name)) = self.preview_warehouse.clone() {
2128            self.start_item_cost_query(cli, wh_id, wh_name, kind, id, name);
2129            return;
2130        }
2131        if let [(wh_name, wh_id, _)] = warehouses.as_slice() {
2132            self.preview_warehouse = Some((wh_id.clone(), wh_name.clone()));
2133            let (wh_id, wh_name) = (wh_id.clone(), wh_name.clone());
2134            self.start_item_cost_query(cli, wh_id, wh_name, kind, id, name);
2135            return;
2136        }
2137        let index = warehouses
2138            .iter()
2139            .position(|(_, _, running)| *running)
2140            .unwrap_or(0);
2141        self.wh_picker = Some(WhPicker {
2142            index,
2143            target: PickTarget::ItemCost(kind, id, name),
2144        });
2145    }
2146
2147    fn start_item_cost_query(
2148        &mut self,
2149        cli: &Arc<DatabricksCli>,
2150        warehouse_id: String,
2151        warehouse_name: String,
2152        kind: fetchers::cost::ResourceKind,
2153        id: String,
2154        name: String,
2155    ) {
2156        self.item_cost = Some(ItemCostView {
2157            warehouse: warehouse_name,
2158            kind,
2159            name,
2160            id: id.clone(),
2161            data: None,
2162        });
2163        let (tx, rx) = oneshot::channel();
2164        self.item_cost_rx = Some(rx);
2165        let cli = Arc::clone(cli);
2166        let host = self.host.clone();
2167        let cached_ws = self.workspace_id.clone();
2168        tokio::spawn(async move {
2169            // Scope usage to this workspace; resolved once, then cached.
2170            let ws = match (cached_ws, host) {
2171                (Some(w), _) => Some(w),
2172                (None, Some(h)) => {
2173                    fetchers::cost::resolve_workspace_id(&cli, &warehouse_id, &h).await
2174                }
2175                (None, None) => None,
2176            };
2177            let result =
2178                fetchers::cost::fetch_resource(&cli, &warehouse_id, kind, &id, ws.as_deref()).await;
2179            let _ = tx.send((result, ws));
2180        });
2181    }
2182
2183    pub fn close_item_cost(&mut self) {
2184        self.item_cost = None;
2185        self.item_cost_rx = None;
2186    }
2187
2188    /// Opens the lineage view for the selected table/view; needs a
2189    /// warehouse since lineage lives in system tables.
2190    pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
2191        if self.focus != Panel::Catalog {
2192            return;
2193        }
2194        let Some(item) = self.selected_item() else {
2195            return;
2196        };
2197        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2198            return;
2199        }
2200        let Some(full_name) = item.id.clone() else {
2201            return;
2202        };
2203        let warehouses = self.warehouses();
2204        if warehouses.is_empty() {
2205            self.flash = Some((
2206                "✗ no SQL warehouse available to query lineage".to_string(),
2207                Instant::now(),
2208            ));
2209            return;
2210        }
2211        if let Some((id, _)) = self.preview_warehouse.clone() {
2212            self.start_lineage_query(cli, full_name, id);
2213            return;
2214        }
2215        if let [(name, id, _)] = warehouses.as_slice() {
2216            self.preview_warehouse = Some((id.clone(), name.clone()));
2217            let id = id.clone();
2218            self.start_lineage_query(cli, full_name, id);
2219            return;
2220        }
2221        let index = warehouses
2222            .iter()
2223            .position(|(_, _, running)| *running)
2224            .unwrap_or(0);
2225        self.wh_picker = Some(WhPicker {
2226            index,
2227            target: PickTarget::Lineage(full_name),
2228        });
2229    }
2230
2231    fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
2232        self.detail = Some(Detail {
2233            panel: Panel::Catalog,
2234            name: full_name.clone(),
2235            id: full_name.clone(),
2236            kind: None,
2237            section: "Lineage",
2238            data: None,
2239            show_raw: false,
2240            scroll: 0,
2241        });
2242        let (tx, rx) = oneshot::channel();
2243        self.detail_rx = Some(rx);
2244        let cli = Arc::clone(cli);
2245        tokio::spawn(async move {
2246            let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
2247            let _ = tx.send(data);
2248        });
2249    }
2250
2251    pub fn close_cost(&mut self) {
2252        self.cost = None;
2253        self.cost_rx = None;
2254    }
2255
2256    /// The fully-qualified name of the selected catalog-pane table/view.
2257    fn selected_table_fqn(&self) -> Option<String> {
2258        if self.focus != Panel::Catalog {
2259            return None;
2260        }
2261        let item = self.selected_item()?;
2262        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2263            return None;
2264        }
2265        item.id.clone()
2266    }
2267
2268    /// Opens the SQL console. With a table/view selected in the catalog
2269    /// pane, the prompt starts as an editable query against it.
2270    pub fn open_sql(&mut self) {
2271        if self.sql.is_none() {
2272            let input = self
2273                .selected_table_fqn()
2274                .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
2275                .unwrap_or_default();
2276            self.sql = Some(SqlConsole {
2277                cursor: input.chars().count(),
2278                input,
2279                warehouse: String::new(),
2280                running: false,
2281                data: None,
2282                last_sql: String::new(),
2283                scroll: 0,
2284                col: 0,
2285                record: false,
2286                rscroll: 0,
2287            });
2288        }
2289    }
2290
2291    pub fn close_sql(&mut self) {
2292        self.sql = None;
2293        self.sql_rx = None;
2294        self.hist_idx = None;
2295        self.hist_draft.clear();
2296        self.hist_search = None;
2297        self.sql_complete = None;
2298    }
2299
2300    /// Tab in the SQL prompt: complete catalog / schema / table / column
2301    /// names from the workspace, fetching each level once per session.
2302    /// A repeat press cycles to the next candidate.
2303    pub fn sql_tab(&mut self, cli: &Arc<DatabricksCli>) {
2304        match &self.sql_complete {
2305            Some(c) if c.loading => return,
2306            Some(_) => {
2307                self.sql_complete_next(1);
2308                return;
2309            }
2310            None => {}
2311        }
2312        let Some(console) = &self.sql else {
2313            return;
2314        };
2315        let (seg_start, context, prefix) = token_at_cursor(&console.input, console.cursor);
2316        // Which cache entry is missing: column names of the FROM table
2317        // matter most for a bare word, then the level being dotted into.
2318        let missing = if context.is_empty() {
2319            from_table(&console.input)
2320                .filter(|fqn| !self.uc_names.contains_key(fqn))
2321                .or_else(|| (!self.uc_names.contains_key("")).then(String::new))
2322        } else {
2323            (!self.uc_names.contains_key(&context)).then(|| context.clone())
2324        };
2325        let loading = missing.is_some();
2326        self.sql_complete = Some(SqlComplete {
2327            items: Vec::new(),
2328            index: 0,
2329            seg_start,
2330            prefix,
2331            context,
2332            loading,
2333        });
2334        match missing {
2335            Some(path) => self.fetch_uc_names(cli, path),
2336            None => self.sql_complete_fill(),
2337        }
2338    }
2339
2340    /// Builds the candidate list from the cache and inserts the first
2341    /// match; single matches complete silently, no popup.
2342    fn sql_complete_fill(&mut self) {
2343        let Some(console) = &self.sql else {
2344            self.sql_complete = None;
2345            return;
2346        };
2347        let input = console.input.clone();
2348        let Some(comp) = &self.sql_complete else {
2349            return;
2350        };
2351        let q = comp.prefix.to_lowercase();
2352        let mut items: Vec<String> = Vec::new();
2353        if comp.context.is_empty() {
2354            if let Some(cols) = from_table(&input).and_then(|fqn| self.uc_names.get(&fqn)) {
2355                items.extend(
2356                    cols.iter()
2357                        .filter(|n| n.to_lowercase().starts_with(&q))
2358                        .cloned(),
2359                );
2360            }
2361            if let Some(cats) = self.uc_names.get("") {
2362                items.extend(
2363                    cats.iter()
2364                        .filter(|n| n.to_lowercase().starts_with(&q))
2365                        .cloned(),
2366                );
2367            }
2368            if !q.is_empty() {
2369                items.extend(
2370                    SQL_KEYWORDS
2371                        .iter()
2372                        .filter(|k| k.to_lowercase().starts_with(&q))
2373                        .map(|k| k.to_string()),
2374                );
2375            }
2376        } else if let Some(names) = self.uc_names.get(&comp.context) {
2377            items.extend(
2378                names
2379                    .iter()
2380                    .filter(|n| n.to_lowercase().starts_with(&q))
2381                    .cloned(),
2382            );
2383        }
2384        items.dedup();
2385        if items.is_empty() {
2386            self.sql_complete = None;
2387            self.flash = Some(("no completions".to_string(), Instant::now()));
2388            return;
2389        }
2390        let single = items.len() == 1;
2391        if let Some(comp) = &mut self.sql_complete {
2392            comp.items = items;
2393            comp.index = 0;
2394        }
2395        self.sql_complete_apply();
2396        if single {
2397            self.sql_complete = None;
2398        }
2399    }
2400
2401    /// Replaces the completed segment with the current candidate.
2402    fn sql_complete_apply(&mut self) {
2403        let Some(comp) = &self.sql_complete else {
2404            return;
2405        };
2406        let candidate = comp.items[comp.index].clone();
2407        let plain = candidate
2408            .chars()
2409            .all(|c| c.is_alphanumeric() || matches!(c, '_' | ' '));
2410        let text = if plain {
2411            candidate
2412        } else {
2413            format!("`{candidate}`")
2414        };
2415        self.sql_replace_segment(comp.seg_start, &text);
2416    }
2417
2418    /// Overwrites seg_start..caret with `text`, caret landing at its end.
2419    fn sql_replace_segment(&mut self, seg_start: usize, text: &str) {
2420        if let Some(console) = &mut self.sql {
2421            let from = byte_at(&console.input, seg_start);
2422            let to = byte_at(&console.input, console.cursor);
2423            console.input.replace_range(from..to, text);
2424            console.cursor = seg_start + text.chars().count();
2425        }
2426    }
2427
2428    /// Tab / shift+tab with the popup open: cycle candidates.
2429    pub fn sql_complete_next(&mut self, delta: i32) {
2430        let Some(comp) = &mut self.sql_complete else {
2431            return;
2432        };
2433        if comp.items.is_empty() {
2434            return;
2435        }
2436        let n = comp.items.len() as i32;
2437        comp.index = (comp.index as i32 + delta).rem_euclid(n) as usize;
2438        self.sql_complete_apply();
2439    }
2440
2441    /// Esc: restore the typed prefix and close the popup.
2442    pub fn sql_complete_cancel(&mut self) {
2443        if let Some(comp) = &self.sql_complete {
2444            let (seg_start, prefix) = (comp.seg_start, comp.prefix.clone());
2445            self.sql_replace_segment(seg_start, &prefix);
2446        }
2447        self.sql_complete = None;
2448    }
2449
2450    /// Keeps the inserted candidate and closes the popup.
2451    pub fn sql_complete_accept(&mut self) {
2452        self.sql_complete = None;
2453    }
2454
2455    fn fetch_uc_names(&mut self, cli: &Arc<DatabricksCli>, path: String) {
2456        let (tx, rx) = oneshot::channel();
2457        self.uc_names_rx = Some(rx);
2458        let cli = Arc::clone(cli);
2459        tokio::spawn(async move {
2460            let names = fetchers::catalog::names(&cli, &path)
2461                .await
2462                .map_err(|e| format!("{e:#}"));
2463            let _ = tx.send((path, names));
2464        });
2465    }
2466
2467    /// Caches fetched completion names and fills the waiting popup.
2468    pub fn poll_uc_names(&mut self) -> bool {
2469        let Some(rx) = &mut self.uc_names_rx else {
2470            return false;
2471        };
2472        match rx.try_recv() {
2473            Ok((path, result)) => {
2474                self.uc_names_rx = None;
2475                match result {
2476                    Ok(names) => {
2477                        self.uc_names.insert(path, names);
2478                        if self.sql_complete.as_ref().is_some_and(|c| c.loading) {
2479                            if let Some(c) = &mut self.sql_complete {
2480                                c.loading = false;
2481                            }
2482                            self.sql_complete_fill();
2483                        }
2484                    }
2485                    Err(e) => {
2486                        self.sql_complete = None;
2487                        let first = e.lines().next().unwrap_or("fetch failed").to_string();
2488                        self.flash = Some((format!("✗ completions: {first}"), Instant::now()));
2489                    }
2490                }
2491                true
2492            }
2493            Err(oneshot::error::TryRecvError::Empty) => false,
2494            Err(oneshot::error::TryRecvError::Closed) => {
2495                self.uc_names_rx = None;
2496                self.sql_complete = None;
2497                true
2498            }
2499        }
2500    }
2501
2502    /// The statement currently in the prompt.
2503    pub fn sql_input(&self) -> Option<String> {
2504        self.sql.as_ref().map(|c| c.input.clone())
2505    }
2506
2507    /// Replaces the prompt contents (after an $EDITOR round-trip).
2508    pub fn sql_set_input(&mut self, s: &str) {
2509        if let Some(console) = &mut self.sql {
2510            console.input = s.to_string();
2511            console.cursor = console.input.chars().count();
2512        }
2513    }
2514
2515    /// The history entry the active Ctrl+R search currently matches.
2516    pub fn hist_search_current(&self) -> Option<&String> {
2517        let (query, nth) = self.hist_search.as_ref()?;
2518        self.sql_history
2519            .iter()
2520            .rev()
2521            .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
2522            .nth(*nth)
2523    }
2524
2525    pub fn hist_search_start(&mut self) {
2526        if self.sql.is_some() {
2527            self.hist_search = Some((String::new(), 0));
2528        }
2529    }
2530
2531    pub fn hist_search_push(&mut self, c: char) {
2532        if let Some((query, nth)) = &mut self.hist_search {
2533            query.push(c);
2534            *nth = 0;
2535        }
2536    }
2537
2538    pub fn hist_search_pop(&mut self) {
2539        if let Some((query, nth)) = &mut self.hist_search {
2540            query.pop();
2541            *nth = 0;
2542        }
2543    }
2544
2545    /// Ctrl+R again: step to the next older match.
2546    pub fn hist_search_older(&mut self) {
2547        let Some((query, nth)) = &self.hist_search else {
2548            return;
2549        };
2550        let q = query.to_lowercase();
2551        let matches = self
2552            .sql_history
2553            .iter()
2554            .filter(|h| h.to_lowercase().contains(&q))
2555            .count();
2556        if nth + 1 < matches {
2557            if let Some((_, n)) = &mut self.hist_search {
2558                *n += 1;
2559            }
2560        }
2561    }
2562
2563    pub fn hist_search_accept(&mut self) {
2564        if let Some(stmt) = self.hist_search_current().cloned() {
2565            self.sql_set_input(&stmt);
2566        }
2567        self.hist_search = None;
2568    }
2569
2570    pub fn hist_search_cancel(&mut self) {
2571        self.hist_search = None;
2572    }
2573
2574    pub fn sql_push(&mut self, c: char) {
2575        if let Some(console) = &mut self.sql {
2576            let at = byte_at(&console.input, console.cursor);
2577            console.input.insert(at, c);
2578            console.cursor += 1;
2579        }
2580    }
2581
2582    /// Backspace: deletes the character before the caret.
2583    pub fn sql_pop(&mut self) {
2584        if let Some(console) = &mut self.sql {
2585            if console.cursor > 0 {
2586                let at = byte_at(&console.input, console.cursor - 1);
2587                console.input.remove(at);
2588                console.cursor -= 1;
2589            }
2590        }
2591    }
2592
2593    /// Delete: removes the character under the caret.
2594    pub fn sql_delete(&mut self) {
2595        if let Some(console) = &mut self.sql {
2596            if console.cursor < console.input.chars().count() {
2597                let at = byte_at(&console.input, console.cursor);
2598                console.input.remove(at);
2599            }
2600        }
2601    }
2602
2603    pub fn sql_left(&mut self) {
2604        if let Some(console) = &mut self.sql {
2605            console.cursor = console.cursor.saturating_sub(1);
2606        }
2607    }
2608
2609    pub fn sql_right(&mut self) {
2610        if let Some(console) = &mut self.sql {
2611            console.cursor = (console.cursor + 1).min(console.input.chars().count());
2612        }
2613    }
2614
2615    /// ↑ at the prompt: step back through history, stashing the draft.
2616    pub fn sql_hist_prev(&mut self) {
2617        let Some(console) = &mut self.sql else {
2618            return;
2619        };
2620        if self.sql_history.is_empty() {
2621            return;
2622        }
2623        let idx = match self.hist_idx {
2624            None => {
2625                self.hist_draft = console.input.clone();
2626                self.sql_history.len() - 1
2627            }
2628            Some(i) => i.saturating_sub(1),
2629        };
2630        self.hist_idx = Some(idx);
2631        console.input = self.sql_history[idx].clone();
2632        console.cursor = console.input.chars().count();
2633    }
2634
2635    /// ↓ at the prompt: step forward, back to the stashed draft at the end.
2636    pub fn sql_hist_next(&mut self) {
2637        let Some(console) = &mut self.sql else {
2638            return;
2639        };
2640        let Some(idx) = self.hist_idx else {
2641            return;
2642        };
2643        if idx + 1 < self.sql_history.len() {
2644            self.hist_idx = Some(idx + 1);
2645            console.input = self.sql_history[idx + 1].clone();
2646        } else {
2647            self.hist_idx = None;
2648            console.input = self.hist_draft.clone();
2649        }
2650        console.cursor = console.input.chars().count();
2651    }
2652
2653    pub fn sql_home(&mut self) {
2654        if let Some(console) = &mut self.sql {
2655            console.cursor = 0;
2656        }
2657    }
2658
2659    pub fn sql_end(&mut self) {
2660        if let Some(console) = &mut self.sql {
2661            console.cursor = console.input.chars().count();
2662        }
2663    }
2664
2665    pub fn sql_scroll(&mut self, delta: i32) {
2666        if let Some(console) = &mut self.sql {
2667            // Record view: pgup/pgdn walk the fields, not the rows.
2668            if console.record {
2669                let max = match &console.data {
2670                    Some(Ok(t)) => t.headers.len().saturating_sub(1) as u16,
2671                    _ => 0,
2672                };
2673                console.rscroll = if delta < 0 {
2674                    console.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2675                } else {
2676                    console.rscroll.saturating_add(delta as u16).min(max)
2677                };
2678                return;
2679            }
2680            let max = match &console.data {
2681                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2682                _ => 0,
2683            };
2684            console.scroll = if delta < 0 {
2685                console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2686            } else {
2687                (console.scroll + delta as usize).min(max)
2688            };
2689        }
2690    }
2691
2692    /// Shift+←/→ in the console: page result columns in the grid,
2693    /// switch rows in record view. Plain ←/→ stay with the caret, since
2694    /// the prompt is still live underneath.
2695    pub fn sql_cols(&mut self, delta: i32) {
2696        if let Some(console) = &mut self.sql {
2697            if console.record {
2698                let max = match &console.data {
2699                    Some(Ok(t)) => t.rows.len().saturating_sub(1),
2700                    _ => 0,
2701                };
2702                console.scroll = if delta < 0 {
2703                    console.scroll.saturating_sub(1)
2704                } else {
2705                    (console.scroll + 1).min(max)
2706                };
2707                console.rscroll = 0;
2708                return;
2709            }
2710            let n = match &console.data {
2711                Some(Ok(t)) => t.headers.len(),
2712                _ => 0,
2713            };
2714            console.col = if delta < 0 {
2715                console.col.saturating_sub(1)
2716            } else {
2717                (console.col + 1).min(n.saturating_sub(1))
2718            };
2719        }
2720    }
2721
2722    /// Ctrl+V in the console: transpose the current row — one field per
2723    /// line, the readable way through a wide result.
2724    pub fn sql_toggle_record(&mut self) {
2725        if let Some(console) = &mut self.sql {
2726            if matches!(&console.data, Some(Ok(t)) if !t.rows.is_empty()) {
2727                console.record = !console.record;
2728                console.rscroll = 0;
2729            }
2730        }
2731    }
2732
2733    /// Runs the typed statement, resolving a warehouse like previews do.
2734    pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2735        let Some(console) = &self.sql else {
2736            return;
2737        };
2738        if console.running {
2739            return;
2740        }
2741        let query = console.input.trim().to_string();
2742        if query.is_empty() {
2743            return;
2744        }
2745        // Remember the statement (skipping immediate repeats) and reset
2746        // any in-progress history browsing.
2747        if self.sql_history.last() != Some(&query) {
2748            self.sql_history.push(query.clone());
2749            save_history(&self.sql_history);
2750        }
2751        self.hist_idx = None;
2752        self.hist_draft.clear();
2753        let warehouses = self.warehouses();
2754        if warehouses.is_empty() {
2755            self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2756            return;
2757        }
2758        if let Some((id, name)) = self.preview_warehouse.clone() {
2759            self.start_sql_query(cli, query, id, name);
2760            return;
2761        }
2762        if let [(name, id, _)] = warehouses.as_slice() {
2763            self.preview_warehouse = Some((id.clone(), name.clone()));
2764            self.start_sql_query(cli, query, id.clone(), name.clone());
2765            return;
2766        }
2767        let index = warehouses
2768            .iter()
2769            .position(|(_, _, running)| *running)
2770            .unwrap_or(0);
2771        self.wh_picker = Some(WhPicker {
2772            index,
2773            target: PickTarget::Sql(query),
2774        });
2775    }
2776
2777    fn start_sql_query(
2778        &mut self,
2779        cli: &Arc<DatabricksCli>,
2780        query: String,
2781        id: String,
2782        name: String,
2783    ) {
2784        if let Some(console) = &mut self.sql {
2785            console.running = true;
2786            console.warehouse = name;
2787            console.scroll = 0;
2788            console.rscroll = 0;
2789            // A new result set has its own shape; start it in the grid.
2790            console.record = false;
2791            console.last_sql = query.clone();
2792        }
2793        // Published by the task once submitted, so Esc can cancel it.
2794        let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2795        self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2796        let (tx, rx) = oneshot::channel();
2797        self.sql_rx = Some(rx);
2798        let cli = Arc::clone(cli);
2799        tokio::spawn(async move {
2800            let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2801            let _ = tx.send(result);
2802        });
2803    }
2804
2805    /// Writes a result set to a timestamped CSV in the working directory
2806    /// and flashes the path.
2807    fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2808        let stamp = std::time::SystemTime::now()
2809            .duration_since(std::time::UNIX_EPOCH)
2810            .map(|d| d.as_secs())
2811            .unwrap_or(0);
2812        let slug: String = label
2813            .chars()
2814            .map(|c| if c.is_alphanumeric() { c } else { '-' })
2815            .collect::<String>()
2816            .trim_matches('-')
2817            .chars()
2818            .take(40)
2819            .collect();
2820        let name = format!("databricks-{slug}-{stamp}.csv");
2821        let msg = match std::fs::write(&name, data.to_csv()) {
2822            Ok(()) => {
2823                let cwd = std::env::current_dir()
2824                    .map(|d| d.display().to_string())
2825                    .unwrap_or_default();
2826                format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2827            }
2828            Err(e) => format!("✗ export failed: {e}"),
2829        };
2830        self.flash = Some((msg, Instant::now()));
2831    }
2832
2833    /// Ctrl+S in the console: export the current results.
2834    pub fn sql_export(&mut self) {
2835        if let Some(SqlConsole {
2836            data: Some(Ok(data)),
2837            last_sql,
2838            ..
2839        }) = &self.sql
2840        {
2841            let (label, data) = (last_sql.clone(), data.clone());
2842            self.export_csv(&label, &data);
2843        }
2844    }
2845
2846    /// ←/→ in a preview: page columns in the grid, switch rows in
2847    /// record view.
2848    pub fn preview_h(&mut self, delta: i32) {
2849        let Some(pv) = &mut self.preview else {
2850            return;
2851        };
2852        if pv.record {
2853            let max = match &pv.data {
2854                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2855                _ => 0,
2856            };
2857            pv.scroll = if delta < 0 {
2858                pv.scroll.saturating_sub(1)
2859            } else {
2860                (pv.scroll + 1).min(max)
2861            };
2862            pv.rscroll = 0;
2863        } else {
2864            let cols = pv.visible_cols().len();
2865            pv.col = if delta < 0 {
2866                pv.col.saturating_sub(1)
2867            } else {
2868                (pv.col + 1).min(cols.saturating_sub(1))
2869            };
2870        }
2871    }
2872
2873    /// `v`/enter in a preview: transposed view of the top visible row.
2874    pub fn preview_toggle_record(&mut self) {
2875        if let Some(pv) = &mut self.preview {
2876            if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2877                pv.record = !pv.record;
2878                pv.rscroll = 0;
2879            }
2880        }
2881    }
2882
2883    pub fn preview_filter_start(&mut self) {
2884        if let Some(pv) = &mut self.preview {
2885            pv.filter.clear();
2886            pv.filter_entry = true;
2887            pv.col = 0;
2888            pv.rscroll = 0;
2889        }
2890    }
2891
2892    pub fn preview_filter_push(&mut self, c: char) {
2893        if let Some(pv) = &mut self.preview {
2894            pv.filter.push(c);
2895            pv.col = 0;
2896            pv.rscroll = 0;
2897        }
2898    }
2899
2900    pub fn preview_filter_pop(&mut self) {
2901        if let Some(pv) = &mut self.preview {
2902            pv.filter.pop();
2903            pv.col = 0;
2904        }
2905    }
2906
2907    pub fn preview_filter_accept(&mut self) {
2908        if let Some(pv) = &mut self.preview {
2909            pv.filter_entry = false;
2910        }
2911    }
2912
2913    pub fn preview_filter_clear(&mut self) {
2914        if let Some(pv) = &mut self.preview {
2915            pv.filter.clear();
2916            pv.filter_entry = false;
2917            pv.col = 0;
2918            pv.rscroll = 0;
2919        }
2920    }
2921
2922    /// `e` in a table preview: export the sampled rows.
2923    pub fn preview_export(&mut self) {
2924        if let Some(Preview {
2925            data: Some(Ok(data)),
2926            name,
2927            ..
2928        }) = &self.preview
2929        {
2930            let (label, data) = (name.clone(), data.clone());
2931            self.export_csv(&label, &data);
2932        }
2933    }
2934
2935    pub fn poll_sql(&mut self) -> bool {
2936        let Some(rx) = &mut self.sql_rx else {
2937            return false;
2938        };
2939        match rx.try_recv() {
2940            Ok(result) => {
2941                // A warehouse that errors shouldn't stay the session
2942                // default — but a user-canceled statement is not its fault.
2943                if let Err(e) = &result {
2944                    if e != "statement canceled" {
2945                        self.preview_warehouse = None;
2946                    }
2947                }
2948                if let Some(console) = &mut self.sql {
2949                    console.running = false;
2950                    console.data = Some(result);
2951                    console.col = 0;
2952                }
2953                self.sql_rx = None;
2954                self.sql_stmt = None;
2955                true
2956            }
2957            Err(oneshot::error::TryRecvError::Empty) => false,
2958            Err(oneshot::error::TryRecvError::Closed) => {
2959                if let Some(console) = &mut self.sql {
2960                    console.running = false;
2961                }
2962                self.sql_rx = None;
2963                true
2964            }
2965        }
2966    }
2967
2968    pub fn poll_cost(&mut self) -> bool {
2969        let Some(rx) = &mut self.cost_rx else {
2970            return false;
2971        };
2972        match rx.try_recv() {
2973            Ok((result, ws)) => {
2974                if result.is_err() {
2975                    self.preview_warehouse = None;
2976                }
2977                if ws.is_some() {
2978                    self.workspace_id = ws;
2979                }
2980                if let Some(cv) = &mut self.cost {
2981                    cv.data = Some(result);
2982                }
2983                self.cost_rx = None;
2984                true
2985            }
2986            Err(oneshot::error::TryRecvError::Empty) => false,
2987            Err(oneshot::error::TryRecvError::Closed) => {
2988                self.cost_rx = None;
2989                true
2990            }
2991        }
2992    }
2993
2994    pub fn poll_item_cost(&mut self) -> bool {
2995        let Some(rx) = &mut self.item_cost_rx else {
2996            return false;
2997        };
2998        match rx.try_recv() {
2999            Ok((result, ws)) => {
3000                if result.is_err() {
3001                    self.preview_warehouse = None;
3002                }
3003                if ws.is_some() {
3004                    self.workspace_id = ws;
3005                }
3006                if let Some(cv) = &mut self.item_cost {
3007                    cv.data = Some(result);
3008                }
3009                self.item_cost_rx = None;
3010                true
3011            }
3012            Err(oneshot::error::TryRecvError::Empty) => false,
3013            Err(oneshot::error::TryRecvError::Closed) => {
3014                self.item_cost_rx = None;
3015                true
3016            }
3017        }
3018    }
3019
3020    pub fn wh_picker_next(&mut self) {
3021        let len = self.warehouses().len();
3022        if let Some(p) = &mut self.wh_picker {
3023            p.index = (p.index + 1).min(len.saturating_sub(1));
3024        }
3025    }
3026
3027    pub fn wh_picker_prev(&mut self) {
3028        if let Some(p) = &mut self.wh_picker {
3029            p.index = p.index.saturating_sub(1);
3030        }
3031    }
3032
3033    pub fn wh_picker_cancel(&mut self) {
3034        self.wh_picker = None;
3035    }
3036
3037    /// Confirms the warehouse choice, remembers it, and starts the preview.
3038    pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
3039        let Some(picker) = self.wh_picker.take() else {
3040            return;
3041        };
3042        let warehouses = self.warehouses();
3043        let Some((name, id, _)) = warehouses.get(picker.index) else {
3044            return;
3045        };
3046        self.preview_warehouse = Some((id.clone(), name.clone()));
3047        // An explicit choice is worth remembering across sessions.
3048        let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
3049        self.config
3050            .warehouses
3051            .insert(profile, (id.clone(), name.clone()));
3052        self.config.save();
3053        match picker.target {
3054            PickTarget::Preview(table) => {
3055                self.start_preview_query(cli, table, id.clone(), name.clone())
3056            }
3057            PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
3058            PickTarget::ItemCost(kind, item_id, item_name) => {
3059                self.start_item_cost_query(cli, id.clone(), name.clone(), kind, item_id, item_name)
3060            }
3061            PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
3062            PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
3063        }
3064    }
3065
3066    fn start_preview_query(
3067        &mut self,
3068        cli: &Arc<DatabricksCli>,
3069        full_name: String,
3070        warehouse_id: String,
3071        warehouse_name: String,
3072    ) {
3073        self.preview = Some(Preview {
3074            name: full_name.clone(),
3075            warehouse: warehouse_name,
3076            warehouse_id: warehouse_id.clone(),
3077            data: None,
3078            scroll: 0,
3079            col: 0,
3080            filter: String::new(),
3081            filter_entry: false,
3082            record: false,
3083            rscroll: 0,
3084        });
3085        let (tx, rx) = oneshot::channel();
3086        self.preview_rx = Some(rx);
3087        let cli = Arc::clone(cli);
3088        tokio::spawn(async move {
3089            let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
3090            let _ = tx.send(result);
3091        });
3092    }
3093
3094    pub fn close_preview(&mut self) {
3095        self.preview = None;
3096        self.preview_rx = None;
3097    }
3098
3099    pub fn poll_preview(&mut self) -> bool {
3100        let Some(rx) = &mut self.preview_rx else {
3101            return false;
3102        };
3103        match rx.try_recv() {
3104            Ok(result) => {
3105                // A warehouse that errors shouldn't stay the session default.
3106                if result.is_err() {
3107                    self.preview_warehouse = None;
3108                }
3109                if let Some(pv) = &mut self.preview {
3110                    pv.data = Some(result);
3111                }
3112                self.preview_rx = None;
3113                true
3114            }
3115            Err(oneshot::error::TryRecvError::Empty) => false,
3116            Err(oneshot::error::TryRecvError::Closed) => {
3117                self.preview_rx = None;
3118                true
3119            }
3120        }
3121    }
3122
3123    pub fn preview_scroll(&mut self, delta: i32) {
3124        if let Some(pv) = &mut self.preview {
3125            // Record view: j/k walk the fields, not the rows.
3126            if pv.record {
3127                let max = pv.visible_cols().len().saturating_sub(1) as u16;
3128                pv.rscroll = if delta < 0 {
3129                    pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
3130                } else {
3131                    pv.rscroll.saturating_add(delta as u16).min(max)
3132                };
3133                return;
3134            }
3135            let max = match &pv.data {
3136                Some(Ok(t)) => t.rows.len().saturating_sub(1),
3137                _ => 0,
3138            };
3139            pv.scroll = if delta < 0 {
3140                pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
3141            } else {
3142                (pv.scroll + delta as usize).min(max)
3143            };
3144        }
3145    }
3146
3147    pub fn poll_uc(&mut self) -> bool {
3148        let Some(rx) = &mut self.uc_rx else {
3149            return false;
3150        };
3151        match rx.try_recv() {
3152            Ok(result) => {
3153                self.shapes[5] = Some(match result {
3154                    Ok(shape) => shape,
3155                    Err(e) => Shape::Text(format!("✗ {e}")),
3156                });
3157                self.updated_at[5] = Some(Instant::now());
3158                self.uc_rx = None;
3159                true
3160            }
3161            Err(oneshot::error::TryRecvError::Empty) => false,
3162            Err(oneshot::error::TryRecvError::Closed) => {
3163                self.uc_rx = None;
3164                true
3165            }
3166        }
3167    }
3168
3169    /// Opens the access view for the selected item: effective UC grants
3170    /// or the workspace object ACL.
3171    pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
3172        if self.focus == Panel::Secrets {
3173            return self.open_secret_acls(cli);
3174        }
3175        let Some(item) = self.selected_item() else {
3176            return;
3177        };
3178        let Some(id) = item.id.clone() else {
3179            return;
3180        };
3181        let (uc, object_type): (bool, &'static str) = match self.focus {
3182            Panel::Catalog => match &item.status {
3183                Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
3184                Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
3185                Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
3186                Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
3187                _ => return,
3188            },
3189            Panel::Clusters => (false, "clusters"),
3190            Panel::Jobs => (false, "jobs"),
3191            Panel::Pipelines => (false, "pipelines"),
3192            Panel::Warehouses => (false, "warehouses"),
3193            Panel::Dashboards => (false, "dashboards"),
3194            // Secrets ACLs are handled by open_secret_acls above.
3195            Panel::Secrets => return,
3196        };
3197        self.detail = Some(Detail {
3198            panel: self.focus,
3199            name: item.name.clone(),
3200            id: id.clone(),
3201            kind: None,
3202            section: "Access",
3203            data: None,
3204            show_raw: false,
3205            scroll: 0,
3206        });
3207        let (tx, rx) = oneshot::channel();
3208        self.detail_rx = Some(rx);
3209        let cli = Arc::clone(cli);
3210        tokio::spawn(async move {
3211            let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
3212            let _ = tx.send(data);
3213        });
3214    }
3215
3216    /// Drills from an open job or pipeline detail into its most recent
3217    /// run/update.
3218    pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
3219        let Some(d) = &self.detail else {
3220            return;
3221        };
3222        let panel = d.panel;
3223        if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
3224            return;
3225        }
3226        let owner_id = d.id.clone();
3227        self.run_view = Some(RunView {
3228            panel,
3229            owner_name: d.name.clone(),
3230            owner_id: owner_id.clone(),
3231            runs: Vec::new(),
3232            idx: 0,
3233            data: None,
3234            show_raw: false,
3235            scroll: 0,
3236            live: false,
3237            output: None,
3238            show_output: false,
3239            show_timeline: false,
3240            show_dag: false,
3241            show_grid: false,
3242            grid: None,
3243            fetched_at: Instant::now(),
3244        });
3245        let (tx, rx) = oneshot::channel();
3246        self.run_rx = Some(rx);
3247        let cli = Arc::clone(cli);
3248        tokio::spawn(async move {
3249            let result = async {
3250                let runs = if panel == Panel::Jobs {
3251                    fetchers::runs::list(&cli, &owner_id).await?
3252                } else {
3253                    fetchers::updates::list(&cli, &owner_id).await?
3254                };
3255                let Some((run_id, _, _)) = runs.first().cloned() else {
3256                    return Err("no runs recorded yet".to_string());
3257                };
3258                let (data, live) = if panel == Panel::Jobs {
3259                    fetchers::runs::fetch(&cli, &run_id).await
3260                } else {
3261                    fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3262                };
3263                Ok((runs, data, live))
3264            }
3265            .await;
3266            let _ = tx.send(RunUpdate::Opened(result));
3267        });
3268    }
3269
3270    pub fn close_run(&mut self) {
3271        self.run_view = None;
3272        self.run_rx = None;
3273        self.grid_rx = None;
3274    }
3275
3276    /// Moves to an older (delta > 0) or newer (delta < 0) run.
3277    pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
3278        if self.run_rx.is_some() {
3279            return;
3280        }
3281        let Some(rv) = &mut self.run_view else {
3282            return;
3283        };
3284        if rv.runs.is_empty() {
3285            return;
3286        }
3287        let new = if delta < 0 {
3288            rv.idx.saturating_sub(delta.unsigned_abs() as usize)
3289        } else {
3290            (rv.idx + delta as usize).min(rv.runs.len() - 1)
3291        };
3292        if new == rv.idx {
3293            return;
3294        }
3295        rv.idx = new;
3296        rv.data = None;
3297        rv.scroll = 0;
3298        rv.show_raw = false;
3299        rv.output = None;
3300        rv.show_output = false;
3301        let run_id = rv.runs[new].0.clone();
3302        self.start_run_fetch(cli, run_id);
3303    }
3304
3305    fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
3306        let Some(rv) = &self.run_view else {
3307            return;
3308        };
3309        let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
3310        let (tx, rx) = oneshot::channel();
3311        self.run_rx = Some(rx);
3312        let cli = Arc::clone(cli);
3313        tokio::spawn(async move {
3314            let (data, live) = if panel == Panel::Jobs {
3315                fetchers::runs::fetch(&cli, &run_id).await
3316            } else {
3317                fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3318            };
3319            let _ = tx.send(RunUpdate::Detail(data, live));
3320        });
3321    }
3322
3323    /// `o` in the run view: toggles the full output/log view, fetching
3324    /// all task outputs on first use.
3325    pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
3326        let Some(rv) = &mut self.run_view else {
3327            return;
3328        };
3329        if rv.panel != Panel::Jobs {
3330            self.flash = Some((
3331                "✗ output view is for job runs — pipeline events are already inline".to_string(),
3332                Instant::now(),
3333            ));
3334            return;
3335        }
3336        if rv.show_output {
3337            rv.show_output = false;
3338            rv.scroll = 0;
3339            return;
3340        }
3341        if rv.output.is_none() && self.run_rx.is_some() {
3342            self.flash = Some((
3343                "⏳ run still loading — try again in a moment".to_string(),
3344                Instant::now(),
3345            ));
3346            return;
3347        }
3348        rv.show_output = true;
3349        rv.scroll = 0;
3350        if rv.output.is_some() {
3351            return;
3352        }
3353        self.start_output_fetch(cli);
3354    }
3355
3356    fn start_output_fetch(&mut self, cli: &Arc<DatabricksCli>) {
3357        let Some(rv) = &self.run_view else {
3358            return;
3359        };
3360        let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
3361            return;
3362        };
3363        let (tx, rx) = oneshot::channel();
3364        self.run_rx = Some(rx);
3365        let cli = Arc::clone(cli);
3366        tokio::spawn(async move {
3367            let (text, live) = fetchers::runs::full_output(&cli, &run_id).await;
3368            let _ = tx.send(RunUpdate::Output(text, live));
3369        });
3370    }
3371
3372    /// `r` in the run view: rerun only the failed tasks of the shown run.
3373    pub fn request_run_repair(&mut self) {
3374        let Some(rv) = &self.run_view else {
3375            return;
3376        };
3377        if rv.panel != Panel::Jobs {
3378            self.flash = Some((
3379                "✗ repair applies to job runs only".to_string(),
3380                Instant::now(),
3381            ));
3382            return;
3383        }
3384        if rv.live {
3385            self.flash = Some((
3386                "✗ run is still executing — cancel it first (s)".to_string(),
3387                Instant::now(),
3388            ));
3389            return;
3390        }
3391        let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
3392            return;
3393        };
3394        if matches!(status, Status::Success) {
3395            self.flash = Some((
3396                "✗ run succeeded — nothing to repair".to_string(),
3397                Instant::now(),
3398            ));
3399            return;
3400        }
3401        self.confirm = Some(Confirm {
3402            message: format!(
3403                "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
3404                rv.owner_name
3405            ),
3406            args: vec![
3407                "jobs".to_string(),
3408                "repair-run".to_string(),
3409                run_id.clone(),
3410                "--rerun-all-failed-tasks".to_string(),
3411            ],
3412            params: None,
3413        });
3414    }
3415
3416    /// `t` in the run view: per-task execution timeline of a job run.
3417    pub fn run_toggle_timeline(&mut self) {
3418        let Some(rv) = &mut self.run_view else {
3419            return;
3420        };
3421        if rv.panel != Panel::Jobs {
3422            self.flash = Some((
3423                "✗ timeline is for job runs — pipeline events are already inline".to_string(),
3424                Instant::now(),
3425            ));
3426            return;
3427        }
3428        rv.show_timeline = !rv.show_timeline;
3429        rv.show_dag = false;
3430        rv.show_grid = false;
3431        rv.scroll = 0;
3432    }
3433
3434    /// `d` in the run view: dependency tree of the run's tasks.
3435    pub fn run_toggle_dag(&mut self) {
3436        let Some(rv) = &mut self.run_view else {
3437            return;
3438        };
3439        if rv.panel != Panel::Jobs {
3440            self.flash = Some((
3441                "✗ the task DAG is for job runs — pipelines have no task graph here".to_string(),
3442                Instant::now(),
3443            ));
3444            return;
3445        }
3446        rv.show_dag = !rv.show_dag;
3447        rv.show_timeline = false;
3448        rv.show_grid = false;
3449        rv.scroll = 0;
3450    }
3451
3452    /// `g` in the run view: history grid — every task's state across the
3453    /// job's recent runs, with duration trends.
3454    pub fn run_toggle_grid(&mut self, cli: &Arc<DatabricksCli>) {
3455        let Some(rv) = &mut self.run_view else {
3456            return;
3457        };
3458        if rv.panel != Panel::Jobs {
3459            self.flash = Some((
3460                "✗ the history grid is for job runs — updates have no task matrix".to_string(),
3461                Instant::now(),
3462            ));
3463            return;
3464        }
3465        rv.show_grid = !rv.show_grid;
3466        rv.show_timeline = false;
3467        rv.show_dag = false;
3468        rv.scroll = 0;
3469        if !rv.show_grid || rv.grid.is_some() || self.grid_rx.is_some() {
3470            return;
3471        }
3472        let job_id = rv.owner_id.clone();
3473        let (tx, rx) = oneshot::channel();
3474        self.grid_rx = Some(rx);
3475        let cli = Arc::clone(cli);
3476        tokio::spawn(async move {
3477            let _ = tx.send(fetchers::runs::grid(&cli, &job_id).await);
3478        });
3479    }
3480
3481    pub fn poll_grid(&mut self) -> bool {
3482        let Some(rx) = &mut self.grid_rx else {
3483            return false;
3484        };
3485        match rx.try_recv() {
3486            Ok(result) => {
3487                self.grid_rx = None;
3488                if let Some(rv) = &mut self.run_view {
3489                    rv.grid = Some(result);
3490                }
3491                true
3492            }
3493            Err(oneshot::error::TryRecvError::Empty) => false,
3494            Err(oneshot::error::TryRecvError::Closed) => {
3495                self.grid_rx = None;
3496                true
3497            }
3498        }
3499    }
3500
3501    pub fn run_toggle_raw(&mut self) {
3502        if let Some(rv) = &mut self.run_view {
3503            rv.show_raw = !rv.show_raw;
3504            rv.scroll = 0;
3505        }
3506    }
3507
3508    pub fn run_scroll(&mut self, delta: i32) {
3509        if let Some(rv) = &mut self.run_view {
3510            rv.scroll = if delta < 0 {
3511                rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
3512            } else {
3513                rv.scroll.saturating_add(delta as u16)
3514            };
3515        }
3516    }
3517
3518    /// Applies run fetch results; also re-polls a live run every few
3519    /// seconds so an executing run's tasks update on their own.
3520    pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3521        if let Some(rx) = &mut self.run_rx {
3522            match rx.try_recv() {
3523                Ok(update) => {
3524                    self.run_rx = None;
3525                    if let Some(rv) = &mut self.run_view {
3526                        match update {
3527                            RunUpdate::Opened(Ok((runs, data, live))) => {
3528                                rv.runs = runs;
3529                                rv.idx = 0;
3530                                rv.data = Some(data);
3531                                rv.live = live;
3532                            }
3533                            RunUpdate::Opened(Err(e)) => {
3534                                rv.data = Some(DetailData {
3535                                    summary: Vec::new(),
3536                                    activity: Vec::new(),
3537                                    raw: format!("✗ {e}"),
3538                                });
3539                                rv.live = false;
3540                            }
3541                            RunUpdate::Detail(data, live) => {
3542                                rv.data = Some(data);
3543                                rv.live = live;
3544                            }
3545                            RunUpdate::Output(text, live) => {
3546                                rv.output = Some(text);
3547                                rv.live = live;
3548                            }
3549                        }
3550                        rv.fetched_at = Instant::now();
3551                    }
3552                    true
3553                }
3554                Err(oneshot::error::TryRecvError::Empty) => false,
3555                Err(oneshot::error::TryRecvError::Closed) => {
3556                    self.run_rx = None;
3557                    true
3558                }
3559            }
3560        } else if let Some(rv) = &self.run_view {
3561            if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
3562                if rv.show_output {
3563                    // Live tail: keep re-fetching output so task results
3564                    // stream in as they finish.
3565                    if rv.output.is_some() {
3566                        self.start_output_fetch(cli);
3567                    }
3568                } else if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
3569                    self.start_run_fetch(cli, run_id);
3570                }
3571            }
3572            false
3573        } else {
3574            false
3575        }
3576    }
3577
3578    pub fn close_detail(&mut self) {
3579        self.detail = None;
3580        self.detail_rx = None;
3581    }
3582
3583    pub fn toggle_raw(&mut self) {
3584        if let Some(d) = &mut self.detail {
3585            d.show_raw = !d.show_raw;
3586            d.scroll = 0;
3587        }
3588    }
3589
3590    /// Applies a finished detail fetch; returns true if the UI should redraw.
3591    pub fn poll_detail(&mut self) -> bool {
3592        let Some(rx) = &mut self.detail_rx else {
3593            return false;
3594        };
3595        match rx.try_recv() {
3596            Ok(data) => {
3597                if let Some(d) = &mut self.detail {
3598                    d.data = Some(data);
3599                }
3600                self.detail_rx = None;
3601                true
3602            }
3603            Err(oneshot::error::TryRecvError::Empty) => false,
3604            Err(oneshot::error::TryRecvError::Closed) => {
3605                self.detail_rx = None;
3606                true
3607            }
3608        }
3609    }
3610
3611    pub fn detail_scroll(&mut self, delta: i32) {
3612        if let Some(d) = &mut self.detail {
3613            let max = match &d.data {
3614                Some(data) if d.show_raw => data.raw.lines().count(),
3615                Some(data) => data.summary.len() + data.activity.len() + 3,
3616                None => 0,
3617            } as u16;
3618            d.scroll = if delta < 0 {
3619                d.scroll.saturating_sub(delta.unsigned_abs() as u16)
3620            } else {
3621                (d.scroll + delta as u16).min(max.saturating_sub(1))
3622            };
3623        }
3624    }
3625
3626    /// Prepares a contextual action for the selected item, pending confirmation:
3627    /// start/stop for clusters, warehouses and pipelines, run-now for jobs.
3628    pub fn request_action(&mut self) {
3629        // Dashboards, Unity Catalog and secrets have no start/stop/run semantics.
3630        if matches!(
3631            self.focus,
3632            Panel::Dashboards | Panel::Catalog | Panel::Secrets
3633        ) {
3634            return;
3635        }
3636        let Some(item) = self.selected_item() else {
3637            return;
3638        };
3639        let Some(id) = item.id.clone() else {
3640            return;
3641        };
3642        let name = item.name.clone();
3643        let active = matches!(
3644            item.status,
3645            Status::Running | Status::Pending | Status::Success
3646        );
3647        let group = self.focus.cli_group();
3648        let (verb, action): (&str, &str) = match self.focus {
3649            Panel::Jobs => ("Run", "run-now"),
3650            Panel::Clusters if active => ("Stop", "delete"),
3651            Panel::Pipelines if active => ("Stop", "stop"),
3652            Panel::Pipelines => ("Start update for", "start-update"),
3653            _ if active => ("Stop", "stop"),
3654            _ => ("Start", "start"),
3655        };
3656        self.confirm = Some(Confirm {
3657            message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
3658            params: (self.focus == Panel::Jobs).then(|| (id.clone(), name.clone())),
3659            args: vec![group.to_string(), action.to_string(), id],
3660        });
3661    }
3662
3663    /// `S` on the jobs pane: pause or resume the selected job's schedule,
3664    /// trigger or continuous mode. No confirm — it's a symmetric toggle,
3665    /// undone by pressing S again.
3666    pub fn request_schedule_toggle(&mut self, cli: &Arc<DatabricksCli>) {
3667        if self.focus != Panel::Jobs {
3668            self.flash = Some((
3669                "✗ schedule pause applies to jobs — focus the Lakeflow pane".to_string(),
3670                Instant::now(),
3671            ));
3672            return;
3673        }
3674        let Some(item) = self.selected_item() else {
3675            return;
3676        };
3677        let Some(id) = item.id.clone() else {
3678            return;
3679        };
3680        let name = item.name.clone();
3681        if self.action_rx.is_some() {
3682            self.flash = Some((
3683                "⏳ another action is still in flight".to_string(),
3684                Instant::now(),
3685            ));
3686            return;
3687        }
3688        self.flash = Some((format!("⏳ toggling schedule of “{name}”…"), Instant::now()));
3689        let (tx, rx) = oneshot::channel();
3690        self.action_rx = Some(rx);
3691        let cli = Arc::clone(cli);
3692        tokio::spawn(async move {
3693            let _ = tx.send(fetchers::jobs::toggle_pause(&cli, &id, &name).await);
3694        });
3695    }
3696
3697    /// `s` in the run view: cancel the shown run/update after a confirm.
3698    pub fn request_run_cancel(&mut self) {
3699        let Some(rv) = &self.run_view else {
3700            return;
3701        };
3702        if !rv.live {
3703            self.flash = Some((
3704                "✗ nothing to cancel — this run already finished".to_string(),
3705                Instant::now(),
3706            ));
3707            return;
3708        }
3709        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3710            return;
3711        };
3712        let (message, args) = if rv.panel == Panel::Jobs {
3713            (
3714                format!("Cancel run {run_id} of “{}”?", rv.owner_name),
3715                vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
3716            )
3717        } else {
3718            (
3719                format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3720                vec![
3721                    "pipelines".to_string(),
3722                    "stop".to_string(),
3723                    rv.owner_id.clone(),
3724                ],
3725            )
3726        };
3727        self.confirm = Some(Confirm {
3728            message,
3729            args,
3730            params: None,
3731        });
3732    }
3733
3734    /// Cancels the in-flight console statement server-side; the polling
3735    /// task then sees CANCELED and surfaces it in the results pane.
3736    pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3737        let id = self
3738            .sql_stmt
3739            .as_ref()
3740            .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3741        let Some(id) = id else {
3742            self.flash = Some((
3743                "✗ statement not submitted yet — try again in a moment".to_string(),
3744                Instant::now(),
3745            ));
3746            return;
3747        };
3748        let cli = Arc::clone(cli);
3749        tokio::spawn(async move {
3750            let path = format!("/api/2.0/sql/statements/{id}/cancel");
3751            let _ = cli.run_action(&["api", "post", &path]).await;
3752        });
3753        self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3754    }
3755
3756    pub fn cancel_confirm(&mut self) {
3757        self.confirm = None;
3758    }
3759
3760    pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3761        let Some(c) = self.confirm.take() else {
3762            return;
3763        };
3764        let base = c.message.trim_end_matches('?').to_string();
3765        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3766
3767        let (tx, rx) = oneshot::channel();
3768        self.action_rx = Some(rx);
3769        let cli = Arc::clone(cli);
3770        tokio::spawn(async move {
3771            let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3772            let result = match cli.run_action(&args).await {
3773                Ok(()) => Ok(format!("✓ {base} — done")),
3774                Err(e) => Err(format!("✗ {e:#}")),
3775            };
3776            let _ = tx.send(result);
3777        });
3778    }
3779
3780    /// `p` on a job-run confirm: swaps the plain trigger for the
3781    /// run-with-parameters prompt, prefilled with the job's defaults.
3782    pub fn open_param_form(&mut self, cli: &Arc<DatabricksCli>) {
3783        let Some((job_id, name)) = self.confirm.take().and_then(|c| c.params) else {
3784            return;
3785        };
3786        self.param_form = Some(ParamForm {
3787            job_id: job_id.clone(),
3788            job: name,
3789            input: String::new(),
3790            cursor: 0,
3791            kind: fetchers::jobs::ParamKind::Notebook,
3792            loading: true,
3793        });
3794        let (tx, rx) = oneshot::channel();
3795        self.param_rx = Some(rx);
3796        let cli = Arc::clone(cli);
3797        tokio::spawn(async move {
3798            let _ = tx.send(fetchers::jobs::params(&cli, &job_id).await);
3799        });
3800    }
3801
3802    /// Delivers the parameter prefill into the open form.
3803    pub fn poll_param(&mut self) -> bool {
3804        let Some(rx) = &mut self.param_rx else {
3805            return false;
3806        };
3807        match rx.try_recv() {
3808            Ok(result) => {
3809                self.param_rx = None;
3810                let Some(form) = &mut self.param_form else {
3811                    return true;
3812                };
3813                match result {
3814                    Ok((pairs, kind)) => {
3815                        form.input = pairs
3816                            .iter()
3817                            .map(|(k, v)| format!("{k}={v}"))
3818                            .collect::<Vec<_>>()
3819                            .join(", ");
3820                        form.cursor = form.input.chars().count();
3821                        form.kind = kind;
3822                        form.loading = false;
3823                    }
3824                    Err(e) => {
3825                        self.param_form = None;
3826                        self.flash = Some((e, Instant::now()));
3827                    }
3828                }
3829                true
3830            }
3831            Err(oneshot::error::TryRecvError::Empty) => false,
3832            Err(oneshot::error::TryRecvError::Closed) => {
3833                self.param_rx = None;
3834                self.param_form = None;
3835                true
3836            }
3837        }
3838    }
3839
3840    pub fn param_push(&mut self, c: char) {
3841        if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3842            let at = byte_at(&form.input, form.cursor);
3843            form.input.insert(at, c);
3844            form.cursor += 1;
3845        }
3846    }
3847
3848    pub fn param_pop(&mut self) {
3849        if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3850            if form.cursor > 0 {
3851                form.cursor -= 1;
3852                let at = byte_at(&form.input, form.cursor);
3853                form.input.remove(at);
3854            }
3855        }
3856    }
3857
3858    pub fn param_left(&mut self) {
3859        if let Some(form) = &mut self.param_form {
3860            form.cursor = form.cursor.saturating_sub(1);
3861        }
3862    }
3863
3864    pub fn param_right(&mut self) {
3865        if let Some(form) = &mut self.param_form {
3866            form.cursor = (form.cursor + 1).min(form.input.chars().count());
3867        }
3868    }
3869
3870    pub fn param_home(&mut self) {
3871        if let Some(form) = &mut self.param_form {
3872            form.cursor = 0;
3873        }
3874    }
3875
3876    pub fn param_end(&mut self) {
3877        if let Some(form) = &mut self.param_form {
3878            form.cursor = form.input.chars().count();
3879        }
3880    }
3881
3882    /// Enter on the parameter prompt: triggers the run with the edited
3883    /// overrides via `run-now --json`; an empty prompt runs as-is.
3884    pub fn param_submit(&mut self, cli: &Arc<DatabricksCli>) {
3885        let Some(form) = &self.param_form else {
3886            return;
3887        };
3888        if form.loading {
3889            return;
3890        }
3891        let pairs = match parse_params(&form.input) {
3892            Ok(pairs) => pairs,
3893            Err(e) => {
3894                self.flash = Some((e, Instant::now()));
3895                return;
3896            }
3897        };
3898        let Some(form) = self.param_form.take() else {
3899            return;
3900        };
3901        let Ok(id) = form.job_id.parse::<u64>() else {
3902            self.flash = Some((format!("✗ bad job id: {}", form.job_id), Instant::now()));
3903            return;
3904        };
3905        let (base, args) = if pairs.is_empty() {
3906            (
3907                format!("Run job “{}”", form.job),
3908                vec!["jobs".to_string(), "run-now".to_string(), form.job_id],
3909            )
3910        } else {
3911            let map: serde_json::Map<String, serde_json::Value> = pairs
3912                .into_iter()
3913                .map(|(k, v)| (k, serde_json::Value::String(v)))
3914                .collect();
3915            let payload =
3916                serde_json::json!({"job_id": id, form.kind.payload_key(): map}).to_string();
3917            (
3918                format!("Run job “{}” with parameters", form.job),
3919                vec![
3920                    "jobs".to_string(),
3921                    "run-now".to_string(),
3922                    "--json".to_string(),
3923                    payload,
3924                ],
3925            )
3926        };
3927        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3928        let (tx, rx) = oneshot::channel();
3929        self.action_rx = Some(rx);
3930        let cli = Arc::clone(cli);
3931        tokio::spawn(async move {
3932            let args: Vec<&str> = args.iter().map(String::as_str).collect();
3933            let result = match cli.run_action(&args).await {
3934                Ok(()) => Ok(format!("✓ {base} — done")),
3935                Err(e) => Err(format!("✗ {e:#}")),
3936            };
3937            let _ = tx.send(result);
3938        });
3939    }
3940
3941    pub fn close_param_form(&mut self) {
3942        self.param_form = None;
3943        self.param_rx = None;
3944    }
3945
3946    /// `W` in the run view: watch the shown run — bell + flash when it
3947    /// finishes, however long that takes and wherever you are by then.
3948    pub fn toggle_watch(&mut self) {
3949        let Some(rv) = &self.run_view else {
3950            return;
3951        };
3952        if rv.panel != Panel::Jobs {
3953            self.flash = Some((
3954                "✗ watching is for job runs — pipeline updates refresh inline".to_string(),
3955                Instant::now(),
3956            ));
3957            return;
3958        }
3959        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3960            return;
3961        };
3962        if let Some(pos) = self.watched.iter().position(|w| w.run_id == *run_id) {
3963            let w = self.watched.remove(pos);
3964            self.flash = Some((
3965                format!("✓ no longer watching run {} of “{}”", w.run_id, w.job),
3966                Instant::now(),
3967            ));
3968            return;
3969        }
3970        if !rv.live {
3971            self.flash = Some((
3972                "✗ this run already finished — nothing to watch".to_string(),
3973                Instant::now(),
3974            ));
3975            return;
3976        }
3977        self.watched.push(Watched {
3978            run_id: run_id.clone(),
3979            job: rv.owner_name.clone(),
3980        });
3981        self.flash = Some((
3982            format!(
3983                "👁 watching run {} of “{}” — bell when it finishes",
3984                run_id, rv.owner_name
3985            ),
3986            Instant::now(),
3987        ));
3988    }
3989
3990    /// Re-checks watched runs every few seconds; a finished one rings
3991    /// the bell, flashes its result and leaves the list.
3992    pub fn poll_watch(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3993        if let Some(rx) = &mut self.watch_rx {
3994            return match rx.try_recv() {
3995                Ok(states) => {
3996                    self.watch_rx = None;
3997                    let mut done: Vec<(String, Status)> = Vec::new();
3998                    for (run_id, state) in states {
3999                        // Errors are left in place: most are transient,
4000                        // and the next poll retries anyway.
4001                        if let Ok((status, false)) = state {
4002                            if let Some(pos) = self.watched.iter().position(|w| w.run_id == run_id)
4003                            {
4004                                done.push((self.watched.remove(pos).job, status));
4005                            }
4006                        }
4007                    }
4008                    if let Some((job, status)) = done.first() {
4009                        let extra = if done.len() > 1 {
4010                            format!(" (+{} more)", done.len() - 1)
4011                        } else {
4012                            String::new()
4013                        };
4014                        self.flash = Some((
4015                            if matches!(status, Status::Failed) {
4016                                format!("✗ watched run of “{job}” FAILED{extra} — ! to inspect")
4017                            } else {
4018                                format!("🔔 “{job}” run finished: {}{extra}", status.label())
4019                            },
4020                            Instant::now(),
4021                        ));
4022                        print!("\x07");
4023                        let _ = std::io::Write::flush(&mut std::io::stdout());
4024                    }
4025                    !done.is_empty()
4026                }
4027                Err(oneshot::error::TryRecvError::Empty) => false,
4028                Err(oneshot::error::TryRecvError::Closed) => {
4029                    self.watch_rx = None;
4030                    false
4031                }
4032            };
4033        }
4034        if self.watched.is_empty() || self.watch_at.elapsed() < WATCH_INTERVAL {
4035            return false;
4036        }
4037        self.watch_at = Instant::now();
4038        let ids: Vec<String> = self.watched.iter().map(|w| w.run_id.clone()).collect();
4039        let (tx, rx) = oneshot::channel();
4040        self.watch_rx = Some(rx);
4041        let cli = Arc::clone(cli);
4042        tokio::spawn(async move {
4043            let mut out = Vec::with_capacity(ids.len());
4044            for id in ids {
4045                out.push((id.clone(), fetchers::runs::state(&cli, &id).await));
4046            }
4047            let _ = tx.send(out);
4048        });
4049        false
4050    }
4051
4052    /// Applies a finished action; refreshes on success. Returns true on change.
4053    pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
4054        let Some(rx) = &mut self.action_rx else {
4055            return false;
4056        };
4057        match rx.try_recv() {
4058            Ok(result) => {
4059                let ok = result.is_ok();
4060                self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
4061                self.action_rx = None;
4062                if ok {
4063                    self.start_refresh(cli);
4064                    // A confirmed action from the run view (cancel/repair)
4065                    // changes the shown run — reflect it without a manual nav.
4066                    if self.run_rx.is_none() {
4067                        let current = self
4068                            .run_view
4069                            .as_ref()
4070                            .and_then(|rv| rv.runs.get(rv.idx).cloned());
4071                        if let Some((run_id, _, _)) = current {
4072                            self.start_run_fetch(cli, run_id);
4073                        }
4074                    }
4075                }
4076                true
4077            }
4078            Err(oneshot::error::TryRecvError::Empty) => false,
4079            Err(oneshot::error::TryRecvError::Closed) => {
4080                self.action_rx = None;
4081                true
4082            }
4083        }
4084    }
4085
4086    /// Drops the flash message once it has been visible long enough.
4087    pub fn expire_flash(&mut self) -> bool {
4088        if let Some((_, since)) = &self.flash {
4089            if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
4090                self.flash = None;
4091                return true;
4092            }
4093        }
4094        false
4095    }
4096
4097    /// Opens the selected item (or the open detail view) in the workspace web UI.
4098    pub fn open_in_browser(&self) {
4099        let Some(host) = &self.host else {
4100            return;
4101        };
4102        let (panel, id) = match &self.detail {
4103            Some(d) => (d.panel, Some(d.id.clone())),
4104            None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
4105        };
4106        let Some(id) = id else {
4107            return;
4108        };
4109        let path = match panel {
4110            Panel::Clusters => format!("compute/clusters/{id}"),
4111            Panel::Jobs => format!("jobs/{id}"),
4112            Panel::Pipelines => format!("pipelines/{id}"),
4113            Panel::Warehouses => format!("sql/warehouses/{id}"),
4114            Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
4115            Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
4116            // Secret scopes have no workspace-UI page.
4117            Panel::Secrets => return,
4118        };
4119        let url = format!("{}/{}", host.trim_end_matches('/'), path);
4120        #[cfg(target_os = "macos")]
4121        let opener = "open";
4122        #[cfg(not(target_os = "macos"))]
4123        let opener = "xdg-open";
4124        let _ = std::process::Command::new(opener).arg(url).spawn();
4125    }
4126
4127    /// Counts of (ok, pending, failed, idle) items across all panels.
4128    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
4129        let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
4130        for shape in self.shapes.iter().flatten() {
4131            if let Shape::List(items) = shape {
4132                for item in items {
4133                    match item.status {
4134                        Status::Running | Status::Success => ok += 1,
4135                        Status::Pending => pending += 1,
4136                        Status::Failed => failed += 1,
4137                        Status::Stopped => idle += 1,
4138                        Status::Unknown(_) => {}
4139                    }
4140                }
4141            }
4142        }
4143        (ok, pending, failed, idle)
4144    }
4145
4146    pub fn last_refresh_age(&self) -> Duration {
4147        self.last_refresh.elapsed()
4148    }
4149
4150    pub fn spinner(&self) -> &'static str {
4151        SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
4152    }
4153
4154    pub fn spinner_frame(&self) -> usize {
4155        self.spinner_frame
4156    }
4157
4158    /// True whenever any background work is in flight — the loop uses this
4159    /// to keep spinners ticking, not just during panel refreshes.
4160    pub fn busy(&self) -> bool {
4161        self.loading
4162            || self.detail_rx.is_some()
4163            || self.action_rx.is_some()
4164            || self.preview_rx.is_some()
4165            || self.cost_rx.is_some()
4166            || self.item_cost_rx.is_some()
4167            || self.sql_rx.is_some()
4168            || self.run_rx.is_some()
4169    }
4170
4171    pub fn tick_spinner(&mut self) {
4172        self.spinner_frame = self.spinner_frame.wrapping_add(1);
4173    }
4174
4175    pub fn toggle_zoom(&mut self) {
4176        self.zoomed = !self.zoomed;
4177    }
4178
4179    pub fn focus_next(&mut self) {
4180        self.cycle_focus(1);
4181    }
4182
4183    pub fn focus_prev(&mut self) {
4184        self.cycle_focus(-1);
4185    }
4186
4187    /// Cycles focus through the visible panes in display order.
4188    fn cycle_focus(&mut self, delta: i32) {
4189        let visible = self.visible_panes();
4190        if visible.is_empty() {
4191            return;
4192        }
4193        let focus_idx = Panel::ALL
4194            .iter()
4195            .position(|p| p == &self.focus)
4196            .unwrap_or(0);
4197        let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
4198        let n = visible.len() as i32;
4199        let next = ((pos as i32 + delta) % n + n) % n;
4200        self.focus = Panel::ALL[visible[next as usize]];
4201    }
4202
4203    pub fn needs_refresh(&self) -> bool {
4204        !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
4205    }
4206
4207    pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
4208        if self.loading {
4209            return;
4210        }
4211        self.loading = true;
4212        self.error = None;
4213        self.last_refresh = Instant::now();
4214
4215        let (tx, rx) = mpsc::unbounded_channel();
4216        self.pending = Some(rx);
4217        self.in_flight = 8;
4218
4219        // One task per source so each panel updates as soon as its fetch lands,
4220        // instead of waiting for the slowest of the five.
4221        macro_rules! spawn_fetch {
4222            ($update:expr, $fetch:path) => {{
4223                let cli = Arc::clone(cli);
4224                let tx = tx.clone();
4225                tokio::spawn(async move {
4226                    let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
4227                    let _ = tx.send($update(result));
4228                });
4229            }};
4230        }
4231
4232        spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
4233        spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
4234        spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
4235        spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
4236        spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
4237        spawn_fetch!(
4238            |s: Result<Shape, String>| Update::Badge(s.ok()),
4239            fetchers::current_user::fetch
4240        );
4241        {
4242            let cli = Arc::clone(cli);
4243            let tx = tx.clone();
4244            let path = self.uc_path.clone();
4245            tokio::spawn(async move {
4246                let result = fetchers::catalog::fetch(&cli, &path)
4247                    .await
4248                    .map_err(|e| format!("{e:#}"));
4249                let _ = tx.send(Update::Panel(5, result));
4250            });
4251        }
4252        {
4253            let cli = Arc::clone(cli);
4254            let tx = tx.clone();
4255            let scope = self.secret_scope.clone();
4256            tokio::spawn(async move {
4257                let result = fetchers::secrets::fetch(&cli, scope.as_deref())
4258                    .await
4259                    .map_err(|e| format!("{e:#}"));
4260                let _ = tx.send(Update::Panel(6, result));
4261            });
4262        }
4263    }
4264
4265    /// Applies any fetch results that have arrived; returns true if the UI should redraw.
4266    pub fn poll_refresh(&mut self) -> bool {
4267        let Some(rx) = &mut self.pending else {
4268            return false;
4269        };
4270        let mut changed = false;
4271        let mut updated_panes: Vec<usize> = Vec::new();
4272        loop {
4273            match rx.try_recv() {
4274                Ok(Update::Panel(i, result)) => {
4275                    match result {
4276                        Ok(mut shape) => {
4277                            // Active work floats to the top of every pane
4278                            // except the catalog, which stays browsable
4279                            // in its natural (alphabetical) order.
4280                            if i != 5 {
4281                                if let Shape::List(items) = &mut shape {
4282                                    items.sort_by_key(|it| {
4283                                        (it.status.rank(), it.history.is_empty())
4284                                    });
4285                                }
4286                            }
4287                            self.shapes[i] = Some(shape);
4288                            self.updated_at[i] = Some(Instant::now());
4289                            updated_panes.push(i);
4290                        }
4291                        // Keep previous data on failure so panels don't blank
4292                        // out — but surface the error if there's nothing yet.
4293                        Err(e) => {
4294                            if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
4295                                self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
4296                            }
4297                        }
4298                    }
4299                    self.in_flight -= 1;
4300                    changed = true;
4301                }
4302                Ok(Update::Badge(badge)) => {
4303                    if badge.is_some() {
4304                        self.user_badge = badge;
4305                    }
4306                    self.in_flight -= 1;
4307                    changed = true;
4308                }
4309                Err(mpsc::error::TryRecvError::Empty) => break,
4310                Err(mpsc::error::TryRecvError::Disconnected) => {
4311                    self.in_flight = 0;
4312                    break;
4313                }
4314            }
4315        }
4316        for i in updated_panes {
4317            self.alert_new_failures(i);
4318        }
4319        if self.in_flight == 0 {
4320            self.loading = false;
4321            self.pending = None;
4322            changed = true;
4323        }
4324        changed
4325    }
4326}
4327
4328#[cfg(test)]
4329mod tests {
4330    use super::{from_table, parse_params, token_at_cursor};
4331
4332    #[test]
4333    fn parse_params_pairs_and_errors() {
4334        assert_eq!(parse_params(""), Ok(vec![]));
4335        assert_eq!(
4336            parse_params(" date=2026-07-18, mode=full "),
4337            Ok(vec![
4338                ("date".to_string(), "2026-07-18".to_string()),
4339                ("mode".to_string(), "full".to_string())
4340            ])
4341        );
4342        // Values keep embedded '='; empty values are fine.
4343        assert_eq!(
4344            parse_params("expr=a=b, flag="),
4345            Ok(vec![
4346                ("expr".to_string(), "a=b".to_string()),
4347                ("flag".to_string(), String::new())
4348            ])
4349        );
4350        assert!(parse_params("no-equals-here").is_err());
4351        assert!(parse_params("=orphan").is_err());
4352    }
4353
4354    #[test]
4355    fn token_bare_word() {
4356        let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
4357        assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
4358    }
4359
4360    #[test]
4361    fn token_dotted_path() {
4362        let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
4363        assert_eq!(
4364            (start, ctx.as_str(), prefix.as_str()),
4365            (25, "main.sales", "or")
4366        );
4367    }
4368
4369    #[test]
4370    fn token_trailing_dot() {
4371        let (start, ctx, prefix) = token_at_cursor("main.", 5);
4372        assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
4373    }
4374
4375    #[test]
4376    fn token_mid_input() {
4377        // Caret inside the statement, not at the end.
4378        let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
4379        assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
4380    }
4381
4382    #[test]
4383    fn from_table_fully_qualified() {
4384        assert_eq!(
4385            from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
4386            Some("main.sales.orders")
4387        );
4388    }
4389
4390    #[test]
4391    fn from_table_rejects_partial_names() {
4392        assert_eq!(from_table("SELECT x FROM orders"), None);
4393        assert_eq!(from_table("SELECT x FROM main.sales."), None);
4394        assert_eq!(from_table("SELECT 1"), None);
4395    }
4396}